diff --git a/legacy/Connection.php b/legacy/Connection.php index 2c595c5..046165f 100644 --- a/legacy/Connection.php +++ b/legacy/Connection.php @@ -1,11 +1,11 @@ createNewConnection( + return $this->createNewConnection( $config->adapter, $config->host, $config->user, $config->password, $config->name ); - - return $connection; } - /** - * @param $adapter - * @param $host - * @param $user - * @param $password - * @param $database - * - * @return Connection - */ - public function createNewConnection($adapter, $host, $user, $password, $database) - { + public function createNewConnection( + string $adapter, + string $host, + string $user, + string $password, + string $database + ): Connection { try { $connection = $this->newConnection(); - $adapter = $connection->newAdapter($adapter); - $connection->setAdapter($adapter); + $adapterInstance = $connection->newAdapter($adapter); + $connection->setAdapter($adapterInstance); $connection->connect($host, $user, $password, $database); $this->initNewConnection($connection); @@ -59,20 +49,14 @@ public function createNewConnection($adapter, $host, $user, $password, $database return $connection; } - /** - * @param $connection - */ - public function initNewConnection($connection) + public function initNewConnection(Connection $connection): void { if ($this->getBootstrap()->getDebugBar()->isEnabled()) { $this->getBootstrap()->getDebugBar()->initDatabaseAdapter($connection->getAdapter()); } } - /** - * @return Connection - */ - public function newConnection() + public function newConnection(): Connection { return new Connection(false); } diff --git a/src/Adapters/AbstractAdapter.php b/src/Adapters/AbstractAdapter.php index e450ca5..a2a81ea 100644 --- a/src/Adapters/AbstractAdapter.php +++ b/src/Adapters/AbstractAdapter.php @@ -1,30 +1,34 @@ hasProfiler()) { - if ($profile = $this->getProfiler()->start()) { + $profile = $this->getProfiler()->start(); + if ($profile !== null) { $profile->setName($sql); $profile->setAdapter($this); } @@ -38,71 +42,56 @@ public function execute($sql) if ($result !== false) { return $result; - } else { - trigger_error($this->error() . " [$sql]", E_USER_WARNING); } + trigger_error($this->error() . " [$sql]", E_USER_WARNING); + return false; } - /** - * @return bool - */ - public function hasProfiler() + public function hasProfiler(): bool { - return is_object($this->_profiler); + return $this->_profiler instanceof Profiler; } - /** - * @return Profiler|null - */ - public function getProfiler() + public function getProfiler(): ?Profiler { return $this->_profiler; } - /** - * @param Profiler $profiler - */ - public function setProfiler($profiler) + public function setProfiler(Profiler $profiler): void { $this->_profiler = $profiler; } - /** - * @param string $sql - */ - abstract public function query($sql); + /** Execute a raw SQL string against the driver. */ + abstract public function query(string $sql): mixed; - abstract public function error(); + abstract public function error(): string; - public function newProfiler() + public function newProfiler(): Profiler { - $profiler = new Profiler(); - - return $profiler; + return new Profiler(); } - abstract public function quote($value); + abstract public function quote(mixed $value): int|float|string; - abstract public function cleanData($data); + abstract public function cleanData(mixed $data): mixed; abstract public function connect( - $host = false, - $user = false, - $password = false, - $database = false, - $newLink = false - ); + string $host = '', + string $user = '', + string $password = '', + string $database = '', + bool $newLink = false + ): mixed; - /** - * @param string $table - */ - abstract public function describeTable($table); + abstract public function describeTable(string $table): array|false; - abstract public function disconnect(); + abstract public function disconnect(): void; - abstract public function lastInsertID(); + abstract public function lastInsertID(): int|string; - abstract public function affectedRows(); + abstract public function affectedRows(): int; } + diff --git a/src/Adapters/AdapterInterface.php b/src/Adapters/AdapterInterface.php index 32376d4..9980afd 100644 --- a/src/Adapters/AdapterInterface.php +++ b/src/Adapters/AdapterInterface.php @@ -1,38 +1,74 @@ >, indexes: array>}|false + */ + public function describeTable(string $table): array|false; - public function quote($value); + /** + * Quote a scalar value for safe embedding in a SQL string. + * + * Prefer using parameterised queries where possible; this method exists + * as a fallback for the legacy query-builder. + */ + public function quote(mixed $value): int|float|string; - public function cleanData($data); + /** + * Escape a raw string value so it is safe to use inside a quoted SQL literal. + * + * @param mixed $data + * @return mixed + */ + public function cleanData(mixed $data): mixed; - public function error(); + /** Return the error message produced by the most recent failed statement. */ + public function error(): string; - public function disconnect(); + /** Close the underlying driver connection. */ + public function disconnect(): void; } + diff --git a/src/Adapters/HasAdapterTrait.php b/src/Adapters/HasAdapterTrait.php index 0d18a3f..2f7da71 100644 --- a/src/Adapters/HasAdapterTrait.php +++ b/src/Adapters/HasAdapterTrait.php @@ -1,66 +1,52 @@ _adapter == null) { + if ($this->_adapter === null) { $this->initAdapter(); } return $this->_adapter; } - /** - * @param $adapter - */ - public function setAdapter($adapter) + public function setAdapter(AbstractAdapter $adapter): void { $this->_adapter = $adapter; } - public function initAdapter() + public function initAdapter(): void { $this->setAdapterName('MySQLi'); } - /** - * @param $name - */ - public function setAdapterName($name) + public function setAdapterName(string $name): void { $this->setAdapter($this->newAdapter($name)); } - /** - * @param $name - * - * @return AbstractAdapter - */ - public function newAdapter($name) + public function newAdapter(string $name): AbstractAdapter { $class = static::getAdapterClass($name); return new $class(); } - /** - * @param $name - * @return string - */ - public static function getAdapterClass($name) + public static function getAdapterClass(string $name): string { - return '\Nip\Database\Adapters\\' . $name; + return '\\Nip\\Database\\Adapters\\' . $name; } } + diff --git a/src/Adapters/MySQLi.php b/src/Adapters/MySQLi.php index d69043d..71bb7c4 100644 --- a/src/Adapters/MySQLi.php +++ b/src/Adapters/MySQLi.php @@ -1,191 +1,173 @@ connection = mysqli_connect($host, $user, $password, $newLink); - - if ($this->connection) { + * Returns the raw mysqli connection on success or null on failure + * (a PHP warning is triggered so the caller can react accordingly). + */ + public function connect( + string $host = '', + string $user = '', + string $password = '', + string $database = '', + bool $newLink = false + ): ?\mysqli { + $this->connection = mysqli_connect($host, $user, $password); + + if ($this->connection instanceof \mysqli) { if ($this->selectDatabase($database)) { return $this->connection; - } else { - $message = 'Cannot select database '.$database; } + $message = 'Cannot select database ' . $database; } else { - $message = mysqli_error($this->connection); + $message = mysqli_connect_error() ?? 'Unknown connection error'; } - if (!$this->connection) { - trigger_error($message, E_USER_WARNING); - } + trigger_error($message, E_USER_WARNING); + + return null; } - /** - * @param $database - * @return bool - */ - public function selectDatabase($database) + public function selectDatabase(string $database): bool { - return mysqli_select_db($this->connection, $database); + return $this->connection instanceof \mysqli + && mysqli_select_db($this->connection, $database); } - /** - * @param $sql - * @return bool|\mysqli_result - */ - public function query($sql) + public function query(string $sql): \mysqli_result|bool { + if (!$this->connection instanceof \mysqli) { + trigger_error('MySQLi adapter has no active connection', E_USER_WARNING); + return false; + } + try { - return mysqli_query($this->connection, $sql); + $result = mysqli_query($this->connection, $sql); + return $result === false ? false : $result; } catch (\Exception $e) { - throw new \Exception($e->getMessage().' for query '.$sql, $e->getCode(), $e); + throw new \RuntimeException($e->getMessage() . ' for query ' . $sql, $e->getCode(), $e); } } - /** - * @return int|string - */ - public function lastInsertID() + public function lastInsertID(): int|string { - return mysqli_insert_id($this->connection); + return $this->connection instanceof \mysqli ? mysqli_insert_id($this->connection) : 0; } - /** - * @return int - */ - public function affectedRows() + public function affectedRows(): int { - return mysqli_affected_rows($this->connection); + return $this->connection instanceof \mysqli ? mysqli_affected_rows($this->connection) : 0; } - /** - * @return string - */ - public function info() + public function info(): string { - return mysqli_info($this->connection); + return $this->connection instanceof \mysqli ? (mysqli_info($this->connection) ?? '') : ''; } - /** - * @param $result - * @return null|object - */ - public function fetchObject($result) + public function fetchObject(mixed $result): ?object { - return mysqli_fetch_object($result); + return ($result instanceof \mysqli_result) ? (mysqli_fetch_object($result) ?: null) : null; } - /** - * @param $result - * @param $row - * @param $field - * @return mixed - */ - public function result($result, $row, $field) + public function result(mixed $result, int $row, string $field): mixed { - return mysqli_result($result, $row, $field); + if (!$result instanceof \mysqli_result) { + return null; + } + mysqli_data_seek($result, $row); + $rowData = mysqli_fetch_assoc($result); + return $rowData[$field] ?? null; } - /** - * @param $result - */ - public function freeResults($result) + public function freeResults(mixed $result): void { - return mysqli_free_result($result); + if ($result instanceof \mysqli_result) { + mysqli_free_result($result); + } } - /** - * @param $table - * @return array|false - */ - public function describeTable($table) + public function describeTable(string $table): array|false { - if (!($this->connection instanceof \mysqli)) { + if (!$this->connection instanceof \mysqli) { return false; } $return = ['fields' => [], 'indexes' => []]; - $result = $this->execute('DESCRIBE '.$table); - if (is_bool($result)) { + $result = $this->execute('DESCRIBE ' . $table); + if ($result === false || is_bool($result)) { return false; } if (mysqli_num_rows($result)) { while ($row = $this->fetchAssoc($result)) { $return['fields'][$row['Field']] = [ - 'field' => $row['Field'], - 'type' => $row['Type'], - 'nullable' => strtoupper($row['Null']) == 'YES', - 'primary' => ( + 'field' => $row['Field'], + 'type' => $row['Type'], + 'nullable' => strtoupper($row['Null']) === 'YES', + 'primary' => ( isset($return['indexes']['PRIMARY']['fields'][0]) - && $return['indexes']['PRIMARY']['fields'][0] == $row['Field'] + && $return['indexes']['PRIMARY']['fields'][0] === $row['Field'] ), - 'default' => $row['Default'], + 'default' => $row['Default'], 'auto_increment' => ($row['Extra'] === 'auto_increment'), ]; } } - $result = $this->execute('SHOW INDEX IN '.$table); - if (is_bool($result)) { + $result = $this->execute('SHOW INDEX IN ' . $table); + if ($result === false || is_bool($result)) { return false; } if (mysqli_num_rows($result)) { while ($row = $this->fetchAssoc($result)) { - if (!isset($return['indexes'][$row['Key_name']])) { - $return['indexes'][$row['Key_name']] = []; + $keyName = $row['Key_name']; + if (!isset($return['indexes'][$keyName])) { + $return['indexes'][$keyName] = []; } - $return['indexes'][$row['Key_name']]['fields'][] = $row['Column_name']; - $return['indexes'][$row['Key_name']]['unique'] = $row['Non_unique'] == '0'; - $return['indexes'][$row['Key_name']]['fulltext'] = $row['Index_type'] == 'FULLTEXT'; - $return['indexes'][$row['Key_name']]['type'] = $row['Index_type']; + $return['indexes'][$keyName]['fields'][] = $row['Column_name']; + $return['indexes'][$keyName]['unique'] = $row['Non_unique'] === '0'; + $return['indexes'][$keyName]['fulltext'] = $row['Index_type'] === 'FULLTEXT'; + $return['indexes'][$keyName]['type'] = $row['Index_type']; } } return $return; } - /** - * @param $result - * @return array|null - */ - public function fetchAssoc($result) + public function fetchAssoc(mixed $result): ?array { - return mysqli_fetch_assoc($result); + return ($result instanceof \mysqli_result) ? (mysqli_fetch_assoc($result) ?: null) : null; } /** - * @return array + * @return array */ - public function getTables() + public function getTables(): array { $return = []; - $result = $this->execute("SHOW FULL TABLES"); - if ($this->numRows($result)) { + $result = $this->execute('SHOW FULL TABLES'); + if ($result instanceof \mysqli_result && $this->numRows($result)) { while ($row = $this->fetchArray($result)) { $return[$row[0]] = [ - "type" => $row[1] == "BASE TABLE" ? "table" : "view", + 'type' => $row[1] === 'BASE TABLE' ? 'table' : 'view', ]; } } @@ -193,67 +175,76 @@ public function getTables() return $return; } - /** - * @param $result - * @return int - */ - public function numRows($result) + public function numRows(mixed $result): int { - return mysqli_num_rows($result); + return ($result instanceof \mysqli_result) ? (int) mysqli_num_rows($result) : 0; } - /** - * @param $result - * @return array|null - */ - public function fetchArray($result) + public function fetchArray(mixed $result): ?array { - return mysqli_fetch_array($result); + return ($result instanceof \mysqli_result) ? (mysqli_fetch_array($result) ?: null) : null; } /** - * @param $value - * @return int|string + * Quote a scalar value for safe embedding in a SQL string. + * + * Numeric values are returned as-is; strings are escaped and wrapped in + * single quotes using the connection's current character set. */ - public function quote($value) + public function quote(mixed $value): int|float|string { - $value = $this->cleanData($value); + $cleaned = $this->cleanData($value); - $intVal = filter_var($value, FILTER_VALIDATE_INT); + $intVal = filter_var($cleaned, FILTER_VALIDATE_INT); if ($intVal !== false) { - return $intVal; + return (int) $intVal; } - $floatVal = filter_var($value, FILTER_VALIDATE_FLOAT); + $floatVal = filter_var($cleaned, FILTER_VALIDATE_FLOAT); if ($floatVal !== false) { - return $floatVal; + return (float) $floatVal; } - return "'$value'"; + return "'{$cleaned}'"; } /** - * @param $data - * @return string + * Escape a string using the connection's current character set. + * + * Falls back to addslashes() when there is no active connection so that + * unit tests without a real database still produce a safe result. */ - public function cleanData($data) + public function cleanData(mixed $data): mixed { - if (empty($data)) { + if ($data === null || $data === '') { + return $data; + } + + if (!is_string($data) && !is_numeric($data)) { return $data; } - return mysqli_real_escape_string($this->connection, $data); + + if ($this->connection instanceof \mysqli) { + return mysqli_real_escape_string($this->connection, (string) $data); + } + + // Fallback: addslashes is not a cryptographic guarantee, but it + // prevents the most obvious injections when there is no live connection + // (e.g. during unit tests that mock the connection). + return addslashes((string) $data); } - /** - * @return string - */ - public function error() + public function error(): string { - return mysqli_error($this->connection); + return $this->connection instanceof \mysqli ? (mysqli_error($this->connection) ?: '') : ''; } - public function disconnect() + public function disconnect(): void { - mysqli_close($this->connection); + if ($this->connection instanceof \mysqli) { + mysqli_close($this->connection); + $this->connection = null; + } } } + diff --git a/src/Adapters/PdoAdapter.php b/src/Adapters/PdoAdapter.php new file mode 100644 index 0000000..6c8c45e --- /dev/null +++ b/src/Adapters/PdoAdapter.php @@ -0,0 +1,351 @@ + pdo_mysql` (or `use_pdo => true`) in your connection config + * and `ConnectionFactory` will wire up this adapter automatically. + * + * ## Manual usage + * ```php + * $pdo = new \PDO('mysql:host=localhost;dbname=app', 'user', 'pass'); + * $adapter = new \Nip\Database\Adapters\PdoAdapter(); + * $adapter->setPdo($pdo); + * + * $conn = new \Nip\Database\Connections\Connection(false); + * $conn->setAdapter($adapter); + * + * // Symfony DBAL-style parameterised query: + * $result = $conn->executeQuery('SELECT * FROM users WHERE id = ?', [42]); + * $result = $conn->executeStatement('UPDATE users SET active=? WHERE id=?', [1, 42]); + * ``` + * + * @package Nip\Database\Adapters + */ +class PdoAdapter extends AbstractAdapter implements AdapterInterface, PreparedStatementAdapterInterface +{ + protected ?PDO $connection = null; + + /** + * Number of rows affected by the most recent DML statement. + * Stored here because PDO exposes this per-statement, not per-connection. + */ + protected int $lastAffectedRows = 0; + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Open a PDO connection and return it (or null on failure). + * + * This method follows the same signature as the MySQLi adapter so that + * `Connection::connect()` can call it transparently. + */ + public function connect( + string $host = '', + string $user = '', + string $password = '', + string $database = '', + bool $newLink = false + ): ?PDO { + $dsn = "mysql:host={$host};dbname={$database};charset=utf8mb4"; + + try { + $this->connection = new PDO($dsn, $user, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + return $this->connection; + } catch (PDOException $e) { + trigger_error($e->getMessage(), E_USER_WARNING); + + return null; + } + } + + /** + * Inject an already-open PDO instance (e.g. created by MySqlConnector). + */ + public function setPdo(PDO $pdo): void + { + $this->connection = $pdo; + } + + public function getPdo(): ?PDO + { + return $this->connection; + } + + public function disconnect(): void + { + $this->connection = null; + } + + // ------------------------------------------------------------------------- + // Query execution (AdapterInterface) + // ------------------------------------------------------------------------- + + /** + * Execute a raw SQL string without parameters. + * + * Used by the legacy query-builder path (values already interpolated). + */ + public function query(string $sql): PDOStatement|false + { + if (!$this->connection instanceof PDO) { + trigger_error('PDO adapter has no active connection', E_USER_WARNING); + + return false; + } + + try { + $stmt = $this->connection->query($sql); + if ($stmt instanceof PDOStatement) { + $this->lastAffectedRows = $stmt->rowCount(); + return $stmt; + } + + return false; + } catch (PDOException $e) { + throw new \RuntimeException($e->getMessage() . ' for query ' . $sql, (int) $e->getCode(), $e); + } + } + + // ------------------------------------------------------------------------- + // PreparedStatementAdapterInterface + // ------------------------------------------------------------------------- + + /** + * Prepare and execute a SQL statement with bound parameters. + * + * This is the preferred path for all parameterised queries. The `?` + * positional placeholders in `$sql` correspond to the values in `$params`. + * + * @param array $params + */ + public function executeWithParams(string $sql, array $params): PDOStatement|false + { + if (!$this->connection instanceof PDO) { + trigger_error('PDO adapter has no active connection', E_USER_WARNING); + + return false; + } + + try { + $stmt = $this->connection->prepare($sql); + $stmt->execute($params); + $this->lastAffectedRows = $stmt->rowCount(); + + return $stmt; + } catch (PDOException $e) { + throw new \RuntimeException($e->getMessage() . ' for query ' . $sql, (int) $e->getCode(), $e); + } + } + + // ------------------------------------------------------------------------- + // Result inspection (AdapterInterface) + // ------------------------------------------------------------------------- + + public function lastInsertID(): int|string + { + return $this->connection instanceof PDO ? $this->connection->lastInsertId() : 0; + } + + public function affectedRows(): int + { + return $this->lastAffectedRows; + } + + public function numRows(mixed $result): int + { + return ($result instanceof PDOStatement) ? $result->rowCount() : 0; + } + + public function fetchArray(mixed $result): ?array + { + return ($result instanceof PDOStatement) + ? ($result->fetch(PDO::FETCH_NUM) ?: null) + : null; + } + + public function fetchAssoc(mixed $result): ?array + { + return ($result instanceof PDOStatement) + ? ($result->fetch(PDO::FETCH_ASSOC) ?: null) + : null; + } + + public function fetchObject(mixed $result): ?object + { + return ($result instanceof PDOStatement) + ? ($result->fetchObject() ?: null) + : null; + } + + /** + * Retrieve a single field from a specific row. + * + * Note: PDO cursors are forward-only; this method buffers the entire result + * set to allow random-access retrieval. Prefer `fetchAssoc()` in hot paths. + */ + public function result(mixed $result, int $row, string $field): mixed + { + if (!$result instanceof PDOStatement) { + return null; + } + + $rows = $result->fetchAll(PDO::FETCH_ASSOC); + + return $rows[$row][$field] ?? null; + } + + public function freeResults(mixed $result): void + { + if ($result instanceof PDOStatement) { + $result->closeCursor(); + } + } + + // ------------------------------------------------------------------------- + // Schema inspection (AdapterInterface) + // ------------------------------------------------------------------------- + + public function describeTable(string $table): array|false + { + if (!$this->connection instanceof PDO) { + return false; + } + + $return = ['fields' => [], 'indexes' => []]; + + $result = $this->execute('DESCRIBE ' . $table); + if ($result === false) { + return false; + } + + while ($row = $this->fetchAssoc($result)) { + $return['fields'][$row['Field']] = [ + 'field' => $row['Field'], + 'type' => $row['Type'], + 'nullable' => strtoupper($row['Null']) === 'YES', + 'primary' => false, + 'default' => $row['Default'], + 'auto_increment' => ($row['Extra'] === 'auto_increment'), + ]; + } + + $result = $this->execute('SHOW INDEX IN ' . $table); + if ($result === false) { + return false; + } + + while ($row = $this->fetchAssoc($result)) { + $keyName = $row['Key_name']; + if (!isset($return['indexes'][$keyName])) { + $return['indexes'][$keyName] = []; + } + $return['indexes'][$keyName]['fields'][] = $row['Column_name']; + $return['indexes'][$keyName]['unique'] = $row['Non_unique'] === '0'; + $return['indexes'][$keyName]['fulltext'] = $row['Index_type'] === 'FULLTEXT'; + $return['indexes'][$keyName]['type'] = $row['Index_type']; + } + + // Stamp the primary-key flag on the matching field + $primaryField = $return['indexes']['PRIMARY']['fields'][0] ?? null; + if ($primaryField !== null && isset($return['fields'][$primaryField])) { + $return['fields'][$primaryField]['primary'] = true; + } + + return $return; + } + + // ------------------------------------------------------------------------- + // Value quoting / escaping (AdapterInterface) + // ------------------------------------------------------------------------- + + /** + * Quote a value for safe embedding in a SQL string. + * + * Prefer parameterised queries (`executeWithParams`) over this method + * wherever possible. + */ + public function quote(mixed $value): int|float|string + { + if (is_int($value)) { + return $value; + } + if (is_float($value)) { + return $value; + } + + if ($this->connection instanceof PDO) { + return $this->connection->quote((string) $value); + } + + return "'" . addslashes((string) $value) . "'"; + } + + /** + * Escape a raw string value for use inside a quoted SQL literal. + * + * Uses `PDO::quote()` (strips the surrounding quotes) when a connection is + * available; falls back to `addslashes()` in unit-test contexts. + */ + public function cleanData(mixed $data): mixed + { + if ($data === null || $data === '') { + return $data; + } + + if (!is_string($data) && !is_numeric($data)) { + return $data; + } + + if ($this->connection instanceof PDO) { + $quoted = $this->connection->quote((string) $data); + // PDO::quote() wraps in single quotes; strip them to match MySQLi behaviour. + return substr($quoted, 1, -1); + } + + return addslashes((string) $data); + } + + // ------------------------------------------------------------------------- + // Error reporting (AdapterInterface) + // ------------------------------------------------------------------------- + + public function error(): string + { + if (!$this->connection instanceof PDO) { + return ''; + } + + $info = $this->connection->errorInfo(); + + return $info[2] ?? ''; + } +} diff --git a/src/Adapters/PreparedStatementAdapterInterface.php b/src/Adapters/PreparedStatementAdapterInterface.php new file mode 100644 index 0000000..8b63be2 --- /dev/null +++ b/src/Adapters/PreparedStatementAdapterInterface.php @@ -0,0 +1,31 @@ + $params Values to bind in order. + * @return mixed Driver statement handle (e.g. \PDOStatement). + */ + public function executeWithParams(string $sql, array $params): mixed; +} diff --git a/src/Adapters/Profiler/Profiler.php b/src/Adapters/Profiler/Profiler.php index b0c60b2..1aa6b9b 100644 --- a/src/Adapters/Profiler/Profiler.php +++ b/src/Adapters/Profiler/Profiler.php @@ -1,42 +1,38 @@ |null */ + public ?array $filterTypes = null; - /** - * @param $id - * @return QueryProfile|\Nip\Profiler\Profile - */ - public function newProfile($id) + public function newProfile(mixed $id): QueryProfile { return new QueryProfile($id); } - /** - * @param $profile - * @return bool - */ - protected function applyFilters($profile) + protected function applyFilters(mixed $profile): bool { if (parent::applyFilters($profile)) { return $this->secondsFilter($profile); } + return false; } - /** - * @param $profile - * @return bool - */ - public function typeFilter($profile) + public function typeFilter(mixed $profile): bool { - if (is_array($this->filterTypes) && in_array($profile->type, $this->filterTypes)) { + if (is_array($this->filterTypes) && in_array($profile->type, $this->filterTypes, true)) { $this->deleteProfile($profile); return false; @@ -45,11 +41,7 @@ public function typeFilter($profile) return true; } - /** - * @param null $queryTypes - * @return $this - */ - public function setFilterQueryType($queryTypes = null) + public function setFilterQueryType(?array $queryTypes = null): static { $this->filterTypes = $queryTypes; diff --git a/src/Adapters/Profiler/QueryProfile.php b/src/Adapters/Profiler/QueryProfile.php index d6c361b..fc4c9f3 100644 --- a/src/Adapters/Profiler/QueryProfile.php +++ b/src/Adapters/Profiler/QueryProfile.php @@ -1,98 +1,69 @@ */ + public array $columns = ['time', 'type', 'memory', 'query', 'affectedRows', 'info']; - /** - * @param null $name - */ - public function setName($name) + public function setName(mixed $name): void { - $this->query = $name; - $this->type = $this->detectQueryType(); + $this->query = (string) $name; + $this->type = $this->detectQueryType(); parent::setName($name); } - /** - * @return string - */ - public function detectQueryType() + public function detectQueryType(): string { - // make sure we have a query type - switch (strtolower(substr($this->query, 0, 6))) { - case 'insert': - return 'INSERT'; - - case 'update': - return 'UPDATE'; - - case 'delete': - return 'DELETE'; - - case 'select': - return 'SELECT'; - - default: - return 'QUERY'; - } - } - - /** - * @return mixed - */ - public function getQuery() - { - return $this->query; + return match (strtolower(substr((string) $this->query, 0, 6))) { + 'insert' => 'INSERT', + 'update' => 'UPDATE', + 'delete' => 'DELETE', + 'select' => 'SELECT', + default => 'QUERY', + }; } - /** - * @return mixed - */ - public function getConnection() + public function getQuery(): ?string { return $this->query; } - public function calculateResources() + public function calculateResources(): void { parent::calculateResources(); $this->getInfo(); } - public function getInfo() + public function getInfo(): void { - $this->info = $this->getAdapter()->info(); + $this->info = $this->getAdapter()->info(); $this->affectedRows = $this->getAdapter()->affectedRows(); } - /** - * @return mixed - */ - public function getAdapter() + public function getAdapter(): mixed { return $this->adapter; } - /** - * @param mixed $adapter - */ - public function setAdapter($adapter) + public function setAdapter(mixed $adapter): void { $this->adapter = $adapter; } diff --git a/src/Connections/Connection.php b/src/Connections/Connection.php index 952bcc5..a6b88d4 100644 --- a/src/Connections/Connection.php +++ b/src/Connections/Connection.php @@ -1,8 +1,11 @@ */ + protected array $config = []; - protected $_query; + /** @var AbstractQuery|string|null */ + protected mixed $_query = null; - protected $_queries = []; + /** @var list */ + protected array $_queries = []; /** - * Create a new database connection instance. - * - * @param \PDO|\Closure $pdo + * @param \PDO|\Closure|false|null $pdo * @param string $database * @param string $tablePrefix - * @param array $config + * @param array $config */ - public function __construct($pdo, $database = '', $tablePrefix = '', $config = []) + public function __construct(mixed $pdo, string $database = '', string $tablePrefix = '', array $config = []) { - $this->pdo = $pdo; - - // First we will setup the default properties. We keep track of the DB - // name we are connected to since it is needed when some reflective - // type commands are run such as checking whether a table exists. - $this->database = $database; - + $this->pdo = $pdo; + $this->database = $database; $this->tablePrefix = $tablePrefix; - $this->config = $config; - - // We need to initialize a query grammar and the query post processors - // which are both very important parts of the database abstractions - // so we initialize these to their default values while starting. -// $this->useDefaultQueryGrammar(); -// $this->useDefaultPostProcessor(); + $this->config = $config; } /** - * Connects to SQL server - * - * @param string $host - * @param string $user - * @param string $password - * @param string $database - * @param bool $newLink - * - * @return static + * {@inheritdoc} */ - public function connect($host, $user, $password, $database, $newLink = false) + public function connect(string $host, string $user, string $password, string $database, bool $newLink = false): static { if (!$this->pdo) { try { $this->pdo = $this->getAdapter()->connect($host, $user, $password, $database, $newLink); if (isset($this->config['charset'])) { - $this->getAdapter()->query('SET CHARACTER SET ' . $this->config['charset']); - $this->getAdapter()->query('SET NAMES ' . $this->config['charset']); + // Validate charset against an allow-list to prevent SQL injection. + $charset = (string) $this->config['charset']; + if (!preg_match('/^[a-zA-Z0-9_]+$/', $charset)) { + throw new \InvalidArgumentException("Invalid charset name: [{$charset}]"); + } + $this->getAdapter()->query('SET CHARACTER SET ' . $charset); + $this->getAdapter()->query('SET NAMES ' . $charset); } if (isset($this->config['modes'])) { - $this->getAdapter()->query("set session sql_mode='{$this->config['modes']}'"); + // Validate modes value to prevent SQL injection. + $modes = (string) $this->config['modes']; + if (!preg_match('/^[a-zA-Z0-9_,]+$/', $modes)) { + throw new \InvalidArgumentException("Invalid sql_mode value: [{$modes}]"); + } + $this->getAdapter()->query("set session sql_mode='{$modes}'"); } $this->setDatabase($database); } catch (Exception $e) { @@ -114,123 +96,154 @@ public function connect($host, $user, $password, $database, $newLink = false) return $this; } - /** - * @return string - */ - public function getDatabase() + public function getDatabase(): string { return $this->database; } - /** - * @param string $database - */ - public function setDatabase($database) + public function setDatabase(string $database): void { $this->database = $database; } - /** - * Prefixes table names - * - * @param string $table - * @return string + * Optionally prefix a table name (no-op by default; override in subclasses). */ - public function tableName($table) + public function tableName(string $table): string { return $table; } /** - * @param string $type optional - * - * @return AbstractQuery|SelectQuery + * {@inheritdoc} */ - public function newSelect() + public function newSelect(): SelectQuery { - return $this->newQuery('select'); + /** @var SelectQuery $query */ + $query = $this->newQuery('select'); + return $query; } /** - * @param string $type optional - * @return AbstractQuery|SelectQuery|UpdateQuery|InsertQuery|DeleteQuery + * {@inheritdoc} */ - public function newQuery($type = "select") + public function newQuery(string $type = 'select'): AbstractQuery { - $className = '\Nip\Database\Query\\' . inflector()->camelize($type); - $query = new $className(); + $className = '\\Nip\\Database\\Query\\' . inflector()->camelize($type); /** @var AbstractQuery $query */ + $query = new $className(); $query->setManager($this); return $query; } /** - * @return InsertQuery + * {@inheritdoc} */ - public function newInsert() + public function newInsert(): InsertQuery { - return $this->newQuery('insert'); + /** @var InsertQuery $query */ + $query = $this->newQuery('insert'); + return $query; } /** - * @return UpdateQuery + * {@inheritdoc} */ - public function newUpdate() + public function newUpdate(): UpdateQuery { - return $this->newQuery('update'); + /** @var UpdateQuery $query */ + $query = $this->newQuery('update'); + return $query; } /** - * @return DeleteQuery + * {@inheritdoc} */ - public function newDelete() + public function newDelete(): DeleteQuery { - return $this->newQuery('delete'); + /** @var DeleteQuery $query */ + $query = $this->newQuery('delete'); + return $query; } /** - * Executes SQL query - * - * @param mixed|AbstractQuery $query - * @return Result + * {@inheritdoc} */ - public function execute($query) + public function execute(AbstractQuery|string $query): Result { $this->_queries[] = $query; - $sql = is_string($query) ? $query : $query->getString(); - + $sql = is_string($query) ? $query : $query->getString(); $resultSQL = $this->getAdapter()->execute($sql); - $result = new Result($resultSQL, $this->getAdapter()); - $result->setQuery($query); + $result = new Result($resultSQL, $this->getAdapter()); + if ($query instanceof AbstractQuery) { + $result->setQuery($query); + } return $result; } /** - * Gets the ID of the last inserted record - * @return int + * {@inheritdoc} */ - public function lastInsertID() + public function lastInsertID(): int|string { return $this->getAdapter()->lastInsertID(); } /** - * Gets the number of rows affected by the last operation - * @return int + * {@inheritdoc} */ - public function affectedRows() + public function affectedRows(): int { return $this->getAdapter()->affectedRows(); } + // ------------------------------------------------------------------------- + // Symfony DBAL-style parameterised execution + // ------------------------------------------------------------------------- + + /** + * {@inheritdoc} + */ + public function executeQuery(string $sql, array $params = []): Result + { + $this->_queries[] = $sql; + + $adapter = $this->getAdapter(); + + if ($params !== [] && $adapter instanceof PreparedStatementAdapterInterface) { + $resultSQL = $adapter->executeWithParams($sql, $params); + } else { + $resultSQL = $adapter->execute($sql); + } + + return new Result($resultSQL, $adapter); + } + /** - * Disconnects from server + * {@inheritdoc} */ - public function disconnect() + public function executeStatement(string $sql, array $params = []): int + { + $this->_queries[] = $sql; + + $adapter = $this->getAdapter(); + + if ($params !== [] && $adapter instanceof PreparedStatementAdapterInterface) { + $adapter->executeWithParams($sql, $params); + } else { + $adapter->execute($sql); + } + + return $adapter->affectedRows(); + } + + /** + * {@inheritdoc} + */ + public function disconnect(): void { if ($this->pdo) { try { @@ -243,37 +256,32 @@ public function disconnect() /** * @param null|string $table - * @return mixed + * @return array{fields: array, indexes: array}|false */ - public function describeTable($table) + public function describeTable(?string $table): array|false { - return $this->getAdapter()->describeTable($this->protect($table)); + return $this->getAdapter()->describeTable($this->protect($table ?? '')); } /** - * Adds backticks to input - * - * @param string $input - * @return string + * {@inheritdoc} */ - public function protect($input) + public function protect(string $input): string { - return str_replace("`*`", "*", '`' . str_replace('.', '`.`', $input) . '`'); + return str_replace('`*`', '*', '`' . str_replace('.', '`.`', $input) . '`'); } /** - * @return array + * @return list */ - public function getQueries() + public function getQueries(): array { return $this->_queries; } - /** - * @return \Closure|PDO - */ - public function getPdo() + public function getPdo(): mixed { return $this->pdo; } } + diff --git a/src/Connections/ConnectionFactory.php b/src/Connections/ConnectionFactory.php index c483e12..f95a051 100644 --- a/src/Connections/ConnectionFactory.php +++ b/src/Connections/ConnectionFactory.php @@ -1,205 +1,174 @@ container = $container ? $container : Container::getInstance(); + $this->container = $container ?? Container::getInstance(); } /** - * Establish a PDO connection based on the configuration. + * Establish a database connection based on the configuration. * - * @param array $config - * @param string $name - * @return Connection + * @param array|Config $config */ - public function make($config, $name = null) + public function make(array|Config $config, ?string $name = null): Connection { $config = $this->parseConfig($config, $name); -// if (isset($config['read'])) { -// return $this->createReadWriteConnection($config); -// } return $this->createSingleConnection($config); } /** - * Parse and prepare the database configuration. + * Normalise the configuration array. * - * @param array $config - * @param string $name - * @return array + * @param array|Config $config + * @param string|null $name + * @return array */ - protected function parseConfig($config, $name) + protected function parseConfig(array|Config $config, ?string $name): array { - return $config; -// return Arr::add(Arr::add($config, 'prefix', ''), 'name', $name); + return $config instanceof Config ? $config->toArray() : $config; } /** - * Create a single database connection instance. + * Create a single connection from a normalised configuration array. + * + * When the config contains `driver => pdo_mysql` or `use_pdo => true` the + * connection is wired with the PDO adapter (via MySqlConnector) instead of + * the legacy MySQLi adapter. All other config keys are unchanged. * - * @param array $config - * @return Connection + * @param array $config */ - protected function createSingleConnection($config) + protected function createSingleConnection(array $config): Connection { - $pdo = $this->createPdoResolver($config); - if (!isset($config['driver'])) { - $config['driver'] = 'mysql'; + $driver = $config['driver'] ?? 'mysql'; + $usePdo = ($driver === 'pdo_mysql') || !empty($config['use_pdo']); + + if ($usePdo) { + return $this->createPdoConnection($config); } - $connection = $this->createConnection($config['driver'], $pdo, $config['database'], $config['prefix'], $config); - $connection->connect($config['host'], $config['username'], $config['password'], $config['database']); + + // $pdoResolver is currently always false (deprecated legacy shim); kept + // here so that createConnection() signature remains intact. + $pdoResolver = $this->createPdoResolver($config); + $connection = $this->createConnection( + $driver, + $pdoResolver, + $config['database'] ?? '', + $config['prefix'] ?? '', + $config + ); + + $connection->connect( + $config['host'] ?? '', + $config['username'] ?? '', + $config['password'] ?? '', + $config['database'] ?? '' + ); return $connection; } /** - * Create a new connection instance. + * Create a Connection backed by the PDO adapter. * - * @param string $driver - * @param boolean $connection - * @param string $database - * @param string $prefix - * @param array $config - * @return Connection + * Uses {@see MySqlConnector} to build the PDO instance so that all the + * Symfony-style charset / timezone / strict-mode configuration is applied. * - * @throws \InvalidArgumentException + * @param array $config */ - protected function createConnection($driver, $connection, $database, $prefix = '', $config = []) + protected function createPdoConnection(array $config): Connection { -// if ($resolver = Connection::getResolver($driver)) { -// return $resolver($connection, $database, $prefix, $config); -// } - switch ($driver) { - case 'mysql': - return new MySqlConnection($connection, $database, $prefix, $config); - } + $pdoInstance = (new MySqlConnector())->connect($config); - throw new InvalidArgumentException("Unsupported driver [$driver]"); - } + $adapter = new PdoAdapter(); + $adapter->setPdo($pdoInstance); - /** - * Create a new Closure that resolves to a PDO instance. - * - * @param array|Config $config - * @return \Closure - */ - protected function createPdoResolver($config) - { - return false; - $config = $config instanceof Config ? $config->toArray() : $config; - return array_key_exists('host', $config) - ? $this->createPdoResolverWithHosts($config) - : $this->createPdoResolverWithoutHosts($config); - } + // Normalise the driver name so `createConnection()` receives 'mysql'. + $config['driver'] = 'mysql'; - /** - * Create a new Closure that resolves to a PDO instance with a specific host or an array of hosts. - * - * @param array $config - * @return \Closure - * - * @throws \PDOException - */ - protected function createPdoResolverWithHosts(array $config) - { - return function () use ($config) { - foreach (Arr::shuffle($hosts = $this->parseHosts($config)) as $key => $host) { - $config['host'] = $host; - - try { - return $this->createConnector($config)->connect($config); - } catch (PDOException $e) { - continue; - } - } - - throw $e; - }; + $connection = $this->createConnection( + 'mysql', + false, + $config['database'] ?? '', + $config['prefix'] ?? '', + $config + ); + + $connection->setAdapter($adapter); + + return $connection; } /** - * Parse the hosts configuration item into an array. + * Instantiate the correct Connection sub-class for the given driver. * - * @param array $config - * @return array + * @param array $config * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ - protected function parseHosts(array $config) - { - $hosts = Arr::wrap($config['host']); - - if (empty($hosts)) { - throw new InvalidArgumentException('Database hosts array is empty.'); - } - - return $hosts; + protected function createConnection( + string $driver, + mixed $connection, + string $database, + string $prefix = '', + array $config = [] + ): Connection { + return match ($driver) { + 'mysql' => new MySqlConnection($connection, $database, $prefix, $config), + default => throw new InvalidArgumentException("Unsupported driver [{$driver}]"), + }; } /** - * Create a new Closure that resolves to a PDO instance where there is no configured host. + * Return a PDO resolver (currently a no-op; kept for future PDO migration). * - * @param array $config - * @return \Closure + * @deprecated Will be implemented properly when PDO support is added. + * @param array|Config $config */ - protected function createPdoResolverWithoutHosts(array $config) + protected function createPdoResolver(array|Config $config): mixed { - return function () use ($config) { - return $this->createConnector($config)->connect($config); - }; + return false; } /** - * Create a connector instance based on the configuration. + * Create a connector instance for the given configuration. * - * @param array $config - * @return \Illuminate\Database\Connectors\ConnectorInterface|MySqlConnector + * @param array $config * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ - public function createConnector(array $config) + public function createConnector(array $config): MySqlConnector { - if (! isset($config['driver'])) { + $driver = $config['driver'] ?? null; + if ($driver === null) { throw new InvalidArgumentException('A driver must be specified.'); } -// if ($this->container->bound($key = "db.connector.{$config['driver']}")) { -// return $this->container->make($key); -// } - - switch ($config['driver']) { - case 'mysql': - return new MySqlConnector(); - } - - throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]."); + return match ($driver) { + 'mysql' => new MySqlConnector(), + default => throw new InvalidArgumentException("Unsupported driver [{$driver}]."), + }; } } + diff --git a/src/Connections/ConnectionInterface.php b/src/Connections/ConnectionInterface.php new file mode 100644 index 0000000..7bc5833 --- /dev/null +++ b/src/Connections/ConnectionInterface.php @@ -0,0 +1,127 @@ +executeQuery('SELECT * FROM users WHERE id = ?', [42]); + * + * // Query builder + * [$sql, $params] = $conn->newSelect()->from('users')->where('id = ?', 42)->toSql(); + * $result = $conn->executeQuery($sql, $params); + * ``` + * + * @param array $params Positional (`?`) binding values. + */ + public function executeQuery(string $sql, array $params = []): Result; + + /** + * Execute a parameterised INSERT / UPDATE / DELETE and return the affected-row count. + * + * ```php + * $affected = $conn->executeStatement( + * 'UPDATE users SET active = ? WHERE id = ?', + * [1, 42] + * ); + * ``` + * + * @param array $params Positional (`?`) binding values. + */ + public function executeStatement(string $sql, array $params = []): int; +} diff --git a/src/Connections/HasConnectionTrait.php b/src/Connections/HasConnectionTrait.php index 89c3774..24e9137 100644 --- a/src/Connections/HasConnectionTrait.php +++ b/src/Connections/HasConnectionTrait.php @@ -1,30 +1,27 @@ connection = $wrapper; + $this->connection = $connection; return $this; } - /** - * @return Connection - */ - public function getConnection() + public function getConnection(): ?Connection { return $this->connection; } } + diff --git a/src/Connections/MySqlConnection.php b/src/Connections/MySqlConnection.php index 4c7fa52..387d991 100644 --- a/src/Connections/MySqlConnection.php +++ b/src/Connections/MySqlConnection.php @@ -1,11 +1,18 @@ */ - protected $options = [ - PDO::ATTR_CASE => PDO::CASE_NATURAL, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, - PDO::ATTR_STRINGIFY_FETCHES => false, - PDO::ATTR_EMULATE_PREPARES => false, + protected array $options = [ + PDO::ATTR_CASE => PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, ]; /** * Create a new PDO connection. * - * @param string $dsn - * @param array $config - * @param array $options - * @return \PDO - * - * @throws \Exception + * @param array $config + * @param array $options */ - public function createConnection($dsn, array $config, array $options) + public function createConnection(string $dsn, array $config, array $options): PDO { - [$username, $password] = [ - $config['username'] ?? null, - $config['password'] ?? null, - ]; + $username = $config['username'] ?? null; + $password = $config['password'] ?? null; - try { - return $this->createPdoConnection( - $dsn, - $username, - $password, - $options - ); - } catch (Exception $e) { - return $this->tryAgainIfCausedByLostConnection( - $e, - $dsn, - $username, - $password, - $options - ); - } + return $this->createPdoConnection($dsn, $username, $password, $options); } /** - * Create a new PDO connection instance. - * - * @param string $dsn - * @param string $username - * @param string $password - * @param array $options - * @return \PDO + * @param array $options */ - protected function createPdoConnection($dsn, $username, $password, $options) + protected function createPdoConnection(string $dsn, ?string $username, ?string $password, array $options): PDO { - if (class_exists(PDOConnection::class) && !$this->isPersistentConnection($options)) { - return new PDOConnection($dsn, $username, $password, $options); - } - return new PDO($dsn, $username, $password, $options); } /** - * Determine if the connection is persistent. - * - * @param array $options - * @return bool + * @param array $options */ - protected function isPersistentConnection($options) + protected function isPersistentConnection(array $options): bool { - return isset($options[PDO::ATTR_PERSISTENT]) && - $options[PDO::ATTR_PERSISTENT]; + return isset($options[PDO::ATTR_PERSISTENT]) && (bool) $options[PDO::ATTR_PERSISTENT]; } /** - * Handle an exception that occurred during connect execution. + * Merge driver-level options with any user-supplied overrides. * - * @param \Throwable $e - * @param string $dsn - * @param string $username - * @param string $password - * @param array $options - * @return \PDO - * - * @throws \Exception + * @param array $config + * @return array */ - protected function tryAgainIfCausedByLostConnection(Throwable $e, $dsn, $username, $password, $options) - { - if ($this->causedByLostConnection($e)) { - return $this->createPdoConnection($dsn, $username, $password, $options); - } - - throw $e; - } - - /** - * Get the PDO options based on the configuration. - * - * @param array $config - * @return array - */ - public function getOptions(array $config) + public function getOptions(array $config): array { $options = $config['options'] ?? []; return array_diff_key($this->options, $options) + $options; } - /** - * Get the default PDO connection options. - * - * @return array - */ - public function getDefaultOptions() + /** @return array */ + public function getDefaultOptions(): array { return $this->options; } - /** - * Set the default PDO connection options. - * - * @param array $options - * @return void - */ - public function setDefaultOptions(array $options) + /** @param array $options */ + public function setDefaultOptions(array $options): void { $this->options = $options; } diff --git a/src/Connectors/MySqlConnector.php b/src/Connectors/MySqlConnector.php index 0beb168..8961516 100644 --- a/src/Connectors/MySqlConnector.php +++ b/src/Connectors/MySqlConnector.php @@ -1,30 +1,29 @@ $config */ - public function connect(array $config) + public function connect(array $config): PDO { - $dsn = $this->getDsn($config); - + $dsn = $this->getDsn($config); $options = $this->getOptions($config); - // We need to grab the PDO options that should be used while making the brand - // new connection instance. The PDO options control various aspects of the - // connection's behavior, and some might be specified by the developers. $connection = $this->createConnection($dsn, $config, $options); if (!empty($config['database'])) { @@ -32,27 +31,15 @@ public function connect(array $config) } $this->configureIsolationLevel($connection, $config); - $this->configureEncoding($connection, $config); - - // Next, we will check to see if a timezone has been specified in this config - // and if it has we will issue a statement to modify the timezone with the - // database. Setting this DB timezone is an optional configuration item. $this->configureTimezone($connection, $config); - $this->setModes($connection, $config); return $connection; } - /** - * Set the connection transaction isolation level. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function configureIsolationLevel($connection, array $config) + /** @param array $config */ + protected function configureIsolationLevel(PDO $connection, array $config): void { if (!isset($config['isolation_level'])) { return; @@ -63,18 +50,11 @@ protected function configureIsolationLevel($connection, array $config) )->execute(); } - - /** - * Set the connection character set and collation. - * - * @param \PDO $connection - * @param array $config - * @return void|\PDO - */ - protected function configureEncoding($connection, array $config) + /** @param array $config */ + protected function configureEncoding(PDO $connection, array $config): void { if (!isset($config['charset'])) { - return $connection; + return; } $connection->prepare( @@ -82,91 +62,54 @@ protected function configureEncoding($connection, array $config) )->execute(); } - /** - * Get the collation for the configuration. - * - * @param array $config - * @return string - */ - protected function getCollation(array $config) + /** @param array $config */ + protected function getCollation(array $config): string { return isset($config['collation']) ? " collate '{$config['collation']}'" : ''; } - /** - * Set the timezone on the connection. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function configureTimezone($connection, array $config) + /** @param array $config */ + protected function configureTimezone(PDO $connection, array $config): void { if (isset($config['timezone'])) { $connection->prepare('set time_zone="' . $config['timezone'] . '"')->execute(); } } - /** - * Create a DSN string from a configuration. - * - * Chooses socket or host/port based on the 'unix_socket' config value. - * - * @param array $config - * @return string - */ - protected function getDsn(array $config) + /** @param array $config */ + protected function getDsn(array $config): string { return $this->hasSocket($config) ? $this->getSocketDsn($config) : $this->getHostDsn($config); } - /** - * Determine if the given configuration array has a UNIX socket value. - * - * @param array $config - * @return bool - */ - protected function hasSocket(array $config) + /** @param array $config */ + protected function hasSocket(array $config): bool { return isset($config['unix_socket']) && !empty($config['unix_socket']); } - /** - * Get the DSN string for a socket configuration. - * - * @param array $config - * @return string - */ - protected function getSocketDsn(array $config) + /** @param array $config */ + protected function getSocketDsn(array $config): string { return "mysql:unix_socket={$config['unix_socket']};dbname={$config['database']}"; } - /** - * Get the DSN string for a host / port configuration. - * - * @param array $config - * @return string - */ - protected function getHostDsn(array $config) + /** @param array $config */ + protected function getHostDsn(array $config): string { - extract($config, EXTR_SKIP); + $host = $config['host'] ?? ''; + $database = $config['database'] ?? ''; + $port = $config['port'] ?? null; - return isset($port) + return $port !== null ? "mysql:host={$host};port={$port};dbname={$database}" : "mysql:host={$host};dbname={$database}"; } - /** - * Set the modes for the connection. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function setModes(PDO $connection, array $config) + /** @param array $config */ + protected function setModes(PDO $connection, array $config): void { if (isset($config['modes'])) { $this->setCustomModes($connection, $config); @@ -179,32 +122,19 @@ protected function setModes(PDO $connection, array $config) } } - /** - * Set the custom modes on the connection. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function setCustomModes(PDO $connection, array $config) + /** @param array $config */ + protected function setCustomModes(PDO $connection, array $config): void { - $modes = implode(',', $config['modes']); - + $modes = implode(',', (array) $config['modes']); $connection->prepare("set session sql_mode='{$modes}'")->execute(); } - /** - * Get the query to enable strict mode. - * - * @param \PDO $connection - * @param array $config - * @return string - */ - protected function strictMode(PDO $connection, $config) + /** @param array $config */ + protected function strictMode(PDO $connection, array $config): string { $version = $config['version'] ?? $connection->getAttribute(PDO::ATTR_SERVER_VERSION); - if (version_compare($version, '8.0.11') >= 0) { + if (version_compare((string) $version, '8.0.11') >= 0) { return "set session sql_mode='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"; } diff --git a/src/DatabaseManager.php b/src/DatabaseManager.php index 18903f7..eebaee8 100644 --- a/src/DatabaseManager.php +++ b/src/DatabaseManager.php @@ -1,15 +1,21 @@ application = $application; - $this->factory = $factory ? $factory : new ConnectionFactory(); + $this->factory = $factory ?? new ConnectionFactory(); } } diff --git a/src/DatabaseServiceProvider.php b/src/DatabaseServiceProvider.php index 13e6906..46bc896 100644 --- a/src/DatabaseServiceProvider.php +++ b/src/DatabaseServiceProvider.php @@ -1,42 +1,34 @@ registerConnectionServices(); } /** * Register the primary database bindings. - * - * @return void */ - protected function registerConnectionServices() + protected function registerConnectionServices(): void { - // The connection factory is used to create the actual connection instances on - // the database. We will inject the factory into the manager so that it may - // make the connections while they are actually needed and not of before. $this->getContainer()->share('db.factory', ConnectionFactory::class); - // The database manager is used to resolve various connections, since multiple - // connections might be managed. It also implements the connection resolver - // interface which may be used by other components requiring connections. $this->getContainer()->share('db', DatabaseManager::class); $this->getContainer()->share('db.connection', function () { @@ -45,9 +37,9 @@ protected function registerConnectionServices() } /** - * @inheritdoc + * {@inheritdoc} */ - public function provides() + public function provides(): array { return ['db', 'db.factory', 'db.connection']; } diff --git a/src/Exception.php b/src/Exception.php index d136dd4..015d062 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -1,11 +1,28 @@ sql; + } +} diff --git a/src/Manager/HasApplication.php b/src/Manager/HasApplication.php index f76f36f..e2ef7c1 100644 --- a/src/Manager/HasApplication.php +++ b/src/Manager/HasApplication.php @@ -1,40 +1,29 @@ application = $application; } - /** - * @return Bootstrap - */ - public function getBootstrap() + public function getBootstrap(): mixed { return $this->application; } - /** - * @param Bootstrap $bootstrap - */ - public function setBootstrap($bootstrap) + public function setBootstrap(mixed $bootstrap): void { $this->application = $bootstrap; } diff --git a/src/Manager/HasConnections.php b/src/Manager/HasConnections.php index befd76f..8ec083e 100644 --- a/src/Manager/HasConnections.php +++ b/src/Manager/HasConnections.php @@ -1,71 +1,51 @@ */ + protected array $connections = []; /** - * Get a database connection instance. - * - * @param string $name - * @return Connection + * Retrieve (and lazily create) a named connection. */ - public function connection($name = null) + public function connection(?string $name = null): Connection { $connectionName = $this->parseConnectionName($name); - if (is_array($connectionName)) { - list($database, $type) = $connectionName; - } else { - $database = $connectionName; - $type = null; - } - $name = $name ?: $database; + $name = $name ?? $connectionName; - // If we haven't created this connection, we'll create it based on the config - // provided in the application. Once we've created the connections we will - // set the "fetch mode" for PDO which determines the query return types. if (!isset($this->connections[$name])) { - $connection = $this->configure($this->makeConnection($name), $type); + $connection = $this->configure($this->makeConnection($name), null); $this->setConnection($connection, $name); } return $this->connections[$name]; } - /** - * @param Connection $connection - * @param string $name - */ - public function setConnection($connection, $name) + public function setConnection(Connection $connection, string $name): void { $this->connections[$name] = $connection; } - /** - * @return array - */ + /** @return array */ public function getConnections(): array { return $this->connections; } - /** - * Get the default connection name. - * - * @return string - */ - public function getDefaultConnection() + public function getDefaultConnection(): string { if (!function_exists('config')) { return 'main'; @@ -76,102 +56,58 @@ public function getDefaultConnection() } if (Container::getInstance() && config()->has('database.default')) { - return config()->get('database.default'); + return (string) config()->get('database.default'); } return 'main'; } - /** - * Parse the connection into an array of the name and read / write type. - * - * @param string $name - * @return string - */ - protected function parseConnectionName($name) + protected function parseConnectionName(?string $name): string { - $name = $name ?: $this->getDefaultConnection(); - - return $name; + return $name ?? $this->getDefaultConnection(); } /** - * Prepare the database connection instance. + * Apply post-creation configuration to the connection. * - * @param Connection $connection - * @param string $type - * @return Connection + * The $type parameter (e.g. 'read'/'write' split) is reserved for a + * future read/write splitting feature and is unused at this time. */ - protected function configure(Connection $connection, $type) + protected function configure(Connection $connection, ?string $type): Connection { -// $connection = $this->setPdoForType($connection, $type); - // First we'll set the fetch mode and a few other dependencies of the database - // connection. This method basically just configures and prepares it to get - // used by the application. Once we're finished we'll return it back out. -// if ($this->app->bound('events')) { -// $connection->setEventDispatcher($this->app['events']); -// } - // Here we'll set a reconnector callback. This reconnector can be any callable - // so we will set a Closure to reconnect from this manager with the name of - // the connection, which will allow us to reconnect from the connections. -// $connection->setReconnector(function ($connection) { -// $this->reconnect($connection->getName()); -// }); return $connection; } - /** - * Make the database connection instance. - * - * @param string $name - * @return Connection - */ - protected function makeConnection($name) + protected function makeConnection(string $name): Connection { $config = $this->configuration($name); - // First we will check by the connection name to see if an extension has been - // registered specifically for that connection. If it has we will call the - // Closure and pass it the config allowing it to resolve the connection. -// if (isset($this->extensions[$name])) { -// return call_user_func($this->extensions[$name], $config, $name); -// } - - // Next we will check to see if an extension has been registered for a driver - // and will call the Closure if so, which allows us to have a more generic - // resolver for the drivers themselves which applies to all connections. -// if (isset($this->extensions[$driver = $config['driver']])) { -// return call_user_func($this->extensions[$driver], $config, $name); -// } return $this->factory->make($config, $name); } /** - * Get the configuration for a connection. + * Retrieve configuration for a named connection. * - * @param string $name - * @return array + * @return array * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ - protected function configuration($name) + protected function configuration(string $name): array { $name = $name ?: $this->getDefaultConnection(); - // To get the database connection configuration, we will just pull each of the - // connection configurations and get the configurations for the given name. - // If the configuration doesn't exist, we'll throw an exception and bail. - $connections = config('database.connections'); - if (is_null($config = Arr::get($connections, $name))) { - throw new InvalidArgumentException("Database [$name] not configured."); + $config = is_array($connections) ? ($connections[$name] ?? null) : null; + + if ($config === null) { + throw new InvalidArgumentException("Database [{$name}] not configured."); } - if ($config['user']) { + if (!empty($config['user'])) { $config['username'] = $config['user']; } - if ($config['name']) { + if (!empty($config['name'])) { $config['database'] = $config['name']; } diff --git a/src/Metadata/Cache.php b/src/Metadata/Cache.php index 78b8d5f..94ada03 100644 --- a/src/Metadata/Cache.php +++ b/src/Metadata/Cache.php @@ -1,18 +1,21 @@ |null */ - public function describeTable($table) + public function describeTable(string $table): ?array { $cacheId = $this->getCacheId($table); return $this->get($cacheId); } - /** - * @param $table - * @return string - */ - public function getCacheId($table) + public function getCacheId(string $table): string { return $this->getConnection()->getDatabase() . '.' . $table; } - /** - * @return Connection - */ - public function getConnection() + public function getConnection(): Connection { return $this->getMetadata()->getConnection(); } - /** - * @return Manager - */ - public function getMetadata() + public function getMetadata(): Manager { return $this->metadata; } - /** - * @param $metadata - * @return $this - */ - public function setMetadata($metadata) + public function setMetadata(Manager $metadata): static { $this->metadata = $metadata; return $this; } - /** - * @param $cacheId - * @return mixed - */ - public function get($cacheId) + public function get(mixed $cacheId): mixed { if (!$this->valid($cacheId)) { $this->reload($cacheId); @@ -80,35 +66,26 @@ public function get($cacheId) return $this->getData($cacheId); } - /** - * @param $cacheId - * @return mixed - */ - public function reload($cacheId) + public function reload(mixed $cacheId): mixed { $data = $this->generate($cacheId); + if (is_array($data) && isset($data['fields'])) { return $this->saveData($cacheId, $data); } + return false; } - /** - * @param $cacheId - * @return mixed - */ - public function generate($cacheId) + public function generate(string $cacheId): mixed { - $data = $this->getConnection()->describeTable($cacheId); - $this->data[$cacheId] = $data; + $data = $this->getConnection()->describeTable($cacheId); + $this->data[$cacheId] = $data; return $data; } - /** - * @return string - */ - public function cachePath() + public function cachePath(): string { return parent::cachePath() . '/db-metadata/'; } diff --git a/src/Metadata/HasMetadata.php b/src/Metadata/HasMetadata.php index 9361c8f..e6a465b 100644 --- a/src/Metadata/HasMetadata.php +++ b/src/Metadata/HasMetadata.php @@ -1,36 +1,31 @@ metadata) { - $this->metadata = new MetadataManager(); + if ($this->metadata === null) { + $this->metadata = new Manager(); $this->metadata->setConnection($this); } return $this->metadata; } - /** - * @param $metadata - * @return static - */ - public function setMetadata($metadata) + public function setMetadata(Manager $metadata): static { $this->metadata = $metadata; return $this; } - -} \ No newline at end of file +} diff --git a/src/Metadata/Manager.php b/src/Metadata/Manager.php index 5cf4163..e1a836d 100644 --- a/src/Metadata/Manager.php +++ b/src/Metadata/Manager.php @@ -1,37 +1,40 @@ , indexes: array} */ - public function describeTable($table) + public function describeTable(string $table): array { $data = $this->getCache()->describeTable($table); + if (!is_array($data)) { - return trigger_error("Cannot load metadata for table [$table]", E_USER_ERROR); + trigger_error("Cannot load metadata for table [{$table}]", E_USER_ERROR); + return []; } return $data; } - /** - * @return Cache - */ - public function getCache() + public function getCache(): Cache { if (!$this->_cache) { $this->_cache = new Cache(); diff --git a/src/Query/AbstractQuery.php b/src/Query/AbstractQuery.php index b0fa556..8d70380 100644 --- a/src/Query/AbstractQuery.php +++ b/src/Query/AbstractQuery.php @@ -1,5 +1,7 @@ */ + protected array $parts = [ 'where' => null, ]; - protected $string = null; + protected ?string $string = null; /** - * @param Connection $manager - * @return $this + * When true, `parseWhere()` and `parseHaving()` emit parameterised SQL + * (i.e. keep `?` placeholders) instead of interpolating values. + * Set exclusively by {@see getParameterizedSql()}. */ - public function setManager(Connection $manager) + private bool $_buildingParameterized = false; + + public function setManager(Connection $manager): static { $this->db = $manager; @@ -47,15 +53,15 @@ public function setManager(Connection $manager) } /** - * @param $name - * @param $arguments - * @return $this + * Magic method: routes set*() calls to initPart() and any other call to addPart(). + * + * @param string $name + * @param array $arguments */ - public function __call($name, $arguments) + public function __call(string $name, array $arguments): static { - if (strpos($name, 'set') === 0) { - $name = str_replace('set', '', $name); - $name[0] = strtolower($name[0]); + if (str_starts_with($name, 'set')) { + $name = lcfirst(substr($name, 3)); $this->initPart($name); } @@ -66,11 +72,7 @@ public function __call($name, $arguments) return $this; } - /** - * @param $name - * @return $this - */ - protected function initPart($name) + protected function initPart(string $name): static { $this->isGenerated(false); $this->parts[$name] = []; @@ -78,11 +80,7 @@ protected function initPart($name) return $this; } - /** - * @param boolean $generated - * @return bool - */ - public function isGenerated($generated = null) + public function isGenerated(bool|null $generated = null): bool { if ($generated === false) { $this->string = null; @@ -91,12 +89,7 @@ public function isGenerated($generated = null) return $this->string !== null; } - /** - * @param $name - * @param $value - * @return $this - */ - protected function addPart($name, $value) + protected function addPart(string $name, mixed $value): static { if (!isset($this->parts[$name])) { $this->initPart($name); @@ -108,10 +101,8 @@ protected function addPart($name, $value) return $this; } - /** - * @param $params - */ - public function addParams($params) + /** @param array $params */ + public function addParams(array $params): void { $this->checkParamSelect($params); $this->checkParamFrom($params); @@ -122,30 +113,24 @@ public function addParams($params) $this->checkParamLimit($params); } - /** - * @param $params - */ - protected function checkParamSelect($params) + /** @param array $params */ + protected function checkParamSelect(array $params): void { if (isset($params['select']) && is_array($params['select'])) { call_user_func_array([$this, 'cols'], $params['select']); } } - /** - * @param $params - */ - protected function checkParamFrom($params) + /** @param array $params */ + protected function checkParamFrom(array $params): void { if (isset($params['from']) && !empty($params['from'])) { $this->from($params['from']); } } - /** - * @param $params - */ - protected function checkParamWhere($params) + /** @param array $params */ + protected function checkParamWhere(array $params): void { if (isset($params['where']) && is_array($params['where'])) { foreach ($params['where'] as $condition) { @@ -154,25 +139,25 @@ protected function checkParamWhere($params) $this->where($condition); continue; } - $condition = (array)$condition; + $condition = (array) $condition; $this->where( $condition[0], - isset($condition[1]) ? $condition[1] : null + $condition[1] ?? null ); } } } /** - * @param $string - * @param array $values - * @return $this + * Add a WHERE clause (AND-chained). + * + * @param Condition|string $string + * @param mixed $values */ - public function where($string, $values = []) + public function where(mixed $string, mixed $values = []): static { - /** @var Condition $this ->_parts[] */ if ($string) { - if (isset($this->parts['where']) && $this->parts['where'] instanceof Condition) { + if ($this->parts['where'] instanceof Condition) { $this->parts['where'] = $this->parts['where']->and_($this->getCondition($string, $values)); } else { $this->parts['where'] = $this->getCondition($string, $values); @@ -183,15 +168,15 @@ public function where($string, $values = []) } /** - * @param string $string - * @param array $values + * Build or return a Condition instance. * - * @return Condition + * @param Condition|string $string + * @param mixed $values */ - public function getCondition($string, $values = []) + public function getCondition(mixed $string, mixed $values = []): Condition { if (!is_object($string)) { - $condition = new Condition($string, $values); + $condition = new Condition((string) $string, $values); $condition->setQuery($this); } else { $condition = $string; @@ -200,55 +185,42 @@ public function getCondition($string, $values = []) return $condition; } - /** - * @param $params - */ - protected function checkParamOrder($params) + /** @param array $params */ + protected function checkParamOrder(array $params): void { if (isset($params['order']) && !empty($params['order'])) { call_user_func_array([$this, 'order'], $params['order']); } } - /** - * @param $params - */ - protected function checkParamGroup($params) + /** @param array $params */ + protected function checkParamGroup(array $params): void { if (isset($params['group']) && !empty($params['group'])) { call_user_func_array([$this, 'group'], [$params['group']]); } } - /** - * @param $params - */ - protected function checkParamHaving($params) + /** @param array $params */ + protected function checkParamHaving(array $params): void { if (isset($params['having']) && !empty($params['having'])) { call_user_func_array([$this, 'having'], [$params['having']]); } } - /** - * @param $params - */ - protected function checkParamLimit($params) + /** @param array $params */ + protected function checkParamLimit(array $params): void { if (isset($params['limit']) && !empty($params['limit'])) { call_user_func_array([$this, 'limit'], [$params['limit']]); } } - /** - * @param integer $start - * @param bool $offset - * @return $this - */ - public function limit($start, $offset = false) + public function limit(int|string $start, int|string|false $offset = false): static { - $this->parts['limit'] = $start; - if ($offset) { + $this->parts['limit'] = (string) $start; + if ($offset !== false) { $this->parts['limit'] .= ',' . $offset; } @@ -256,12 +228,12 @@ public function limit($start, $offset = false) } /** - * @param $string - * @param array $values + * Add a WHERE clause (OR-chained). * - * @return $this + * @param Condition|string $string + * @param mixed $values */ - public function orWhere($string, $values = []) + public function orWhere(mixed $string, mixed $values = []): static { if ($string) { if ($this->parts['where'] instanceof Condition) { @@ -275,140 +247,178 @@ public function orWhere($string, $values = []) } /** - * @param $string - * @param array $values + * Add a HAVING clause (AND-chained). * - * @return $this + * @param Condition|string $string + * @param mixed $values */ - public function having($string, $values = []) + public function having(mixed $string, mixed $values = []): static { if (empty($string)) { return $this; } - $condition = $this->getCondition($string, $values); - $having = $this->getPart('having'); + $condition = $this->getCondition($string, $values); + $having = $this->getPart('having'); if ($having instanceof Condition) { - $having = $having->and_($this->getCondition($string, $values)); + $having = $having->and_($condition); } else { $having = $condition; } $this->parts['having'] = $having; + return $this; } - /** - * Escapes data for safe use in SQL queries - * - * @param string $data - * @return string - */ - public function cleanData($data) + /** Escape a value for safe use in a SQL literal. */ + public function cleanData(mixed $data): mixed { return $this->getManager()->getAdapter()->cleanData($data); } - /** - * @return Connection - */ - public function getManager() + public function getManager(): Connection { return $this->db; } - /** - * @return Result - */ - public function execute() + public function execute(): Result { return $this->getManager()->execute($this); } - /** - * Implements magic method. - * - * @return string This object as a Query string. - */ - public function __toString() + public function __toString(): string { return $this->getString(); } - /** - * @return string - */ - public function getString() + public function getString(): string { if ($this->string === null) { - $this->string = (string)$this->assemble(); + $this->string = (string) $this->assemble(); } return $this->string; } + abstract public function assemble(): string; + + // ------------------------------------------------------------------------- + // Prepared-statement / Symfony DBAL-style support + // ------------------------------------------------------------------------- + /** - * @return null + * Return a tuple of `[sql, bindings]` ready for a PDO prepared statement. + * + * The SQL contains `?` positional placeholders; the bindings array holds + * the corresponding values in left-to-right order. + * + * Compatible with + * {@see \Nip\Database\Connections\Connection::executeQuery()} and + * {@see \Nip\Database\Connections\Connection::executeStatement()}. + * + * ```php + * [$sql, $params] = $query->from('users')->where('id = ?', 42)->toSql(); + * $result = $conn->executeQuery($sql, $params); + * ``` + * + * @return array{0: string, 1: list} */ - abstract public function assemble(); + public function toSql(): array + { + return [$this->getParameterizedSql(), $this->getBindings()]; + } /** - * @return array + * Assemble the SQL with `?` placeholders left intact (values NOT interpolated). + * + * The normal `getString()` / `assemble()` path is unaffected; this method + * temporarily sets a flag so that `parseWhere()` and `parseHaving()` emit + * parameterised fragments instead. */ - public function getParts() + public function getParameterizedSql(): string { - return $this->parts; + // Save and reset the assembled-string cache so assemble() runs fresh. + $cached = $this->string; + $this->string = null; + + $this->_buildingParameterized = true; + $sql = $this->assemble(); + $this->_buildingParameterized = false; + + // Restore the original cached string so repeated getString() calls are + // not affected. + $this->string = $cached; + + return $sql; } /** - * @return null|string + * Return a flat list of all binding values for the current WHERE + HAVING + * conditions, in left-to-right order matching the `?` placeholders in + * {@see getParameterizedSql()}. + * + * @return list */ - protected function assembleWhere() + public function getBindings(): array { - $where = $this->parseWhere(); + $bindings = []; - if (!empty($where)) { - return " WHERE $where"; + if ($this->parts['where'] instanceof Condition) { + foreach ($this->parts['where']->getBindings() as $b) { + $bindings[] = $b; + } } - return null; + if (isset($this->parts['having']) && $this->parts['having'] instanceof Condition) { + foreach ($this->parts['having']->getBindings() as $b) { + $bindings[] = $b; + } + } + + return $bindings; } - /** - * @return string - */ - protected function parseWhere() + /** @return array */ + public function getParts(): array { - return is_object($this->parts['where']) ? (string)$this->parts['where'] : ''; + return $this->parts; } - /** - * @return null|string - */ - protected function assembleLimit() + protected function assembleWhere(): string + { + $where = $this->parseWhere(); + + return !empty($where) ? " WHERE {$where}" : ''; + } + + protected function parseWhere(): string + { + if (!($this->parts['where'] instanceof Condition)) { + return ''; + } + + return $this->_buildingParameterized + ? $this->parts['where']->getParameterizedString() + : (string) $this->parts['where']; + } + + protected function assembleLimit(): string { $limit = $this->getPart('limit'); if (!empty($limit)) { - return " LIMIT {$this->parts['limit']}"; + return " LIMIT {$limit}"; } - return null; + return ''; } - /** - * @param string $name - * @return mixed|null - */ - public function getPart($name) + public function getPart(string $name): mixed { return $this->hasPart($name) ? $this->parts[$name] : null; } - /** - * @param $name - * @return bool - */ - public function hasPart($name) + public function hasPart(string $name): bool { if (!isset($this->parts[$name])) { return false; @@ -419,20 +429,14 @@ public function hasPart($name) if (is_array($this->parts[$name]) && count($this->parts[$name]) < 1) { return false; } - if (is_string($this->parts[$name]) && empty($this->parts[$name])) { + if (is_string($this->parts[$name]) && $this->parts[$name] === '') { return false; } return true; } - /** - * @param $name - * @param $value - * - * @return $this - */ - protected function setPart($name, $value) + protected function setPart(string $name, mixed $value): static { $this->initPart($name); $this->addPart($name, $value); @@ -440,41 +444,33 @@ protected function setPart($name, $value) return $this; } - /** - * @return string - * @return mixed - */ - protected function getTable() + protected function getTable(): string { - if (!is_array($this->parts['table']) && count($this->parts['table']) < 1) { + if (!is_array($this->parts['table']) || count($this->parts['table']) < 1) { trigger_error('No Table defined', E_USER_WARNING); + return ''; } - return reset($this->parts['table']); + return (string) reset($this->parts['table']); } - /** - * @return string - */ - protected function parseHaving() + protected function parseHaving(): string { - if (isset($this->parts['having'])) { - return (string)$this->parts['having']; + if (!isset($this->parts['having'])) { + return ''; } - return ''; + if ($this->_buildingParameterized && $this->parts['having'] instanceof Condition) { + return $this->parts['having']->getParameterizedString(); + } + + return (string) $this->parts['having']; } - /** - * Parses ORDER BY entries. - * Parses ORDER BY entries - * - * @return string - */ - protected function parseOrder() + protected function parseOrder(): string { if (!isset($this->parts['order']) || !is_array($this->parts['order']) || count($this->parts['order']) < 1) { - return false; + return ''; } $orderParts = []; @@ -485,11 +481,11 @@ protected function parseOrder() $itemOrder = [$itemOrder]; } - $column = isset($itemOrder[0]) ? $itemOrder[0] : false; - $type = isset($itemOrder[1]) ? $itemOrder[1] : ''; - $protected = isset($itemOrder[2]) ? $itemOrder[2] : true; + $column = $itemOrder[0] ?? false; + $type = $itemOrder[1] ?? ''; + $protected = $itemOrder[2] ?? true; - $column = ($protected ? $this->protect($column) : $column) . ' ' . strtoupper($type); + $column = ($protected ? $this->protect((string) $column) : (string) $column) . ' ' . strtoupper((string) $type); $orderParts[] = trim($column); } @@ -499,39 +495,23 @@ protected function parseOrder() } /** - * Adds backticks to input. - * - * @param string $input + * Wrap an identifier in back-ticks. * - * @return string + * Function calls (containing '(') are left as-is. */ - protected function protect($input) + protected function protect(string $input): string { - return strpos($input, '(') !== false ? $input : str_replace( - "`*`", - "*", - '`' . str_replace('.', '`.`', $input) . '`' - ); + return str_contains($input, '(') + ? $input + : str_replace('`*`', '*', '`' . str_replace('.', '`.`', $input) . '`'); } - /** - * Prefixes table names - * - * @param string $table - * @return string - */ - protected function tableName($table = '') + protected function tableName(string $table = ''): string { return $this->getManager()->tableName($table); } - /** - * Removes backticks from input - * - * @param string $input - * @return string - */ - protected function cleanProtected($input) + protected function cleanProtected(string $input): string { return str_replace('`', '', $input); } diff --git a/src/Query/Condition/AndCondition.php b/src/Query/Condition/AndCondition.php index bfe3c5b..0528636 100644 --- a/src/Query/Condition/AndCondition.php +++ b/src/Query/Condition/AndCondition.php @@ -1,24 +1,52 @@ _condition = $condition; + $this->_condition = $condition; $this->_andCondition = $andCondition; } - public function getString() + public function getString(): string + { + return $this->protectCondition($this->_condition->getString()) + . ' AND ' + . $this->protectCondition($this->_andCondition->getString()); + } + + /** + * Return the parameterised SQL template (both sides combined with AND). + */ + public function getParameterizedString(): string + { + return $this->protectCondition($this->_condition->getParameterizedString()) + . ' AND ' + . $this->protectCondition($this->_andCondition->getParameterizedString()); + } + + /** + * Return bindings from both sides in left-to-right order. + * + * @return list + */ + public function getBindings(): array { - return $this->protectCondition($this->_condition->getString()) . " AND " . $this->protectCondition($this->_andCondition->getString()) . ""; + return array_merge( + $this->_condition->getBindings(), + $this->_andCondition->getBindings() + ); } } diff --git a/src/Query/Condition/Condition.php b/src/Query/Condition/Condition.php index 60575a0..917b2a6 100644 --- a/src/Query/Condition/Condition.php +++ b/src/Query/Condition/Condition.php @@ -1,137 +1,224 @@ _string = $string; $this->_values = $values; } - /** - * @return string - */ - public function __toString() + public function __toString(): string { return $this->getString(); } - /** - * @return string - */ - public function getString() + public function getString(): string { return $this->parseString($this->_string, $this->_values); } /** - * Parses $string and replaces all instances of "?" with corresponding $values. - * - * @param string $string - * @param array $values - * - * @return string + * Replace every `?` placeholder with the corresponding quoted value. */ - public function parseString($string, $values) + public function parseString(string $string, mixed $values): string { $positions = []; - $pos = 0; - $offset = 0; + $offset = 0; - while (($pos = strpos($string, "?", $offset)) !== false) { + while (($pos = strpos($string, '?', $offset)) !== false) { $positions[] = $pos; - $offset = $pos + 1; + $offset = $pos + 1; } $count = count($positions); - if ($count == 1) { + if ($count === 1) { $values = [$values]; } for ($i = 0; $i < $count; $i++) { $value = $values[$i]; + if ($value instanceof Query) { $value = $this->parseValueQuery($value); } elseif (is_array($value)) { foreach ($value as $key => $subvalue) { - if (trim($subvalue) != '') { - $value[$key] = is_numeric($subvalue) ? $subvalue : $this->getQuery()->getManager()->getAdapter()->quote($subvalue); + if (trim((string) $subvalue) !== '') { + $value[$key] = is_numeric($subvalue) + ? $subvalue + : $this->getQuery()->getManager()->getAdapter()->quote($subvalue); } else { unset($value[$key]); } } $value = '(' . implode(', ', $value) . ')'; } elseif (is_int($value) || is_float($value)) { + // numeric – use as-is } else { $value = $this->getQuery()->getManager()->getAdapter()->quote($values[$i]); } - $string = substr_replace($string, $value, strpos($string, '?'), 1); + + $string = substr_replace($string, (string) $value, strpos($string, '?'), 1); } return $string; } - /** - * @param Query $value - */ - protected function parseValueQuery($value) + protected function parseValueQuery(Query $value): string { - return "(" . $value->assemble() . ")"; + return '(' . $value->assemble() . ')'; } - /** - * @return Query - */ - public function getQuery() + public function getQuery(): ?Query { return $this->_query; } - /** - * @param Query $query - * @return $this - */ - public function setQuery($query) + public function setQuery(Query $query): static { $this->_query = $query; return $this; } + // ------------------------------------------------------------------------- + // Prepared-statement support + // ------------------------------------------------------------------------- + /** - * @param Condition $condition + * Return the SQL template with `?` positional placeholders intact. + * + * For array values the single `?` is expanded to `(?,?,…)` so the + * returned string is ready to be passed to a PDO prepared statement. + * Sub-query values are rendered inline (they cannot be parameterised). + * + * @return string SQL fragment, e.g. `"id = ?"` or `"status IN (?,?)"` */ - public function and_($condition) + public function getParameterizedString(): string { - return new AndCondition($this, $condition); + $string = $this->_string; + $values = $this->_values; + + $count = substr_count($string, '?'); + + if ($count === 0) { + return $string; + } + + // Normalise single-placeholder case to an array for uniform iteration. + if ($count === 1) { + $values = [$values]; + } + + if (!is_array($values)) { + return $string; + } + + foreach ($values as $value) { + if ($value instanceof Query) { + // Sub-query: render parameterised SQL inline. + $inline = '(' . $value->getParameterizedSql() . ')'; + $pos = strpos($string, '?'); + if ($pos !== false) { + $string = substr_replace($string, $inline, $pos, 1); + } + } elseif (is_array($value)) { + // IN / NOT IN list: expand one `?` to `(?,?,…)`. + $placeholders = implode(', ', array_fill(0, count($value), '?')); + $pos = strpos($string, '?'); + if ($pos !== false) { + $string = substr_replace($string, '(' . $placeholders . ')', $pos, 1); + } + } + // Scalar: leave the single `?` in place. + } + + return $string; } /** - * @param Condition $condition + * Return a flat list of binding values corresponding to the `?` placeholders + * produced by {@see getParameterizedString()}. + * + * Sub-query bindings are recursively merged in left-to-right order. + * + * @return list */ - public function or_($condition) + public function getBindings(): array + { + $values = $this->_values; + + if ($values === null || $values === []) { + return []; + } + + $count = substr_count($this->_string, '?'); + + if ($count === 0) { + return []; + } + + if ($count === 1) { + $values = [$values]; + } + + if (!is_array($values)) { + return []; + } + + $bindings = []; + + foreach ($values as $value) { + if ($value instanceof Query) { + // Recursively include the sub-query's bindings. + foreach ($value->getBindings() as $b) { + $bindings[] = $b; + } + } elseif (is_array($value)) { + foreach ($value as $v) { + $bindings[] = $v; + } + } else { + $bindings[] = $value; + } + } + + return $bindings; + } + + public function and_(Condition $condition): AndCondition + { + return new AndCondition($this, $condition); + } + + public function or_(Condition $condition): OrCondition { return new OrCondition($this, $condition); } - public function protectCondition($condition) + public function protectCondition(string $condition): string { - return strpos($condition, ' AND ') || strpos($condition, ' OR ') ? '(' . $condition . ')' : $condition; + return (str_contains($condition, ' AND ') || str_contains($condition, ' OR ')) + ? '(' . $condition . ')' + : $condition; } } diff --git a/src/Query/Condition/OrCondition.php b/src/Query/Condition/OrCondition.php index 3597856..73cbe14 100644 --- a/src/Query/Condition/OrCondition.php +++ b/src/Query/Condition/OrCondition.php @@ -1,32 +1,52 @@ _condition = $condition; + $this->_orCondition = $orCondition; + } + + public function getString(): string + { + return $this->protectCondition($this->_condition->getString()) + . ' OR ' + . $this->protectCondition($this->_orCondition->getString()); + } /** - * OrCondition constructor. - * @param $condition - * @param $orCondition + * Return the parameterised SQL template (both sides combined with OR). */ - public function __construct($condition, $orCondition) + public function getParameterizedString(): string { - $this->_condition = $condition; - $this->_orCondition = $orCondition; + return $this->protectCondition($this->_condition->getParameterizedString()) + . ' OR ' + . $this->protectCondition($this->_orCondition->getParameterizedString()); } /** - * @return string + * Return bindings from both sides in left-to-right order. + * + * @return list */ - public function getString() + public function getBindings(): array { - return $this->protectCondition($this->_condition->getString()) . ' OR ' . $this->protectCondition($this->_orCondition->getString()) . ''; + return array_merge( + $this->_condition->getBindings(), + $this->_orCondition->getBindings() + ); } } diff --git a/src/Query/Delete.php b/src/Query/Delete.php index d24e496..0f2c356 100644 --- a/src/Query/Delete.php +++ b/src/Query/Delete.php @@ -1,26 +1,24 @@ getManager()->protect($this->getTable())}"; - + $query = 'DELETE FROM ' . $this->getManager()->protect($this->getTable()); $query .= $this->assembleWhere(); $order = $this->parseOrder(); if (!empty($order)) { - $query .= " order by {$order}"; + $query .= " ORDER BY {$order}"; } $query .= $this->assembleLimit(); diff --git a/src/Query/Insert.php b/src/Query/Insert.php index eb5640d..f03ce78 100644 --- a/src/Query/Insert.php +++ b/src/Query/Insert.php @@ -1,23 +1,23 @@ protect($this->getTable()); + $return = 'INSERT INTO ' . $this->protect($this->getTable()); $return .= $this->parseCols(); $return .= $this->parseValues(); $return .= $this->parseOnDuplicate(); @@ -25,48 +25,40 @@ public function assemble() return $return; } - /** - * @return string - */ - public function parseCols() + public function parseCols(): string { if (isset($this->parts['data'][0]) && is_array($this->parts['data'][0])) { $this->setCols(array_keys($this->parts['data'][0])); } - return $this->_cols ? ' (' . implode(',', array_map([$this, 'protect'], $this->_cols)) . ')' : ''; + + return $this->_cols + ? ' (' . implode(',', array_map([$this, 'protect'], $this->_cols)) . ')' + : ''; } - /** - * @param array|string $cols - * @return $this - */ - public function setCols($cols = null) + public function setCols(?array $cols = null): static { $this->_cols = $cols; return $this; } - /** - * @return string|false - */ - public function parseValues() + public function parseValues(): string { if ($this->_values instanceof AbstractQuery) { return ' ' . (string) $this->_values; - } elseif (is_array($this->parts['data'])) { + } + + if (is_array($this->parts['data'] ?? null)) { return $this->parseData(); } - return false; + + return ''; } - /** - * Parses INSERT data - * - * @return string - */ - protected function parseData() + protected function parseData(): string { $values = []; + foreach ($this->parts['data'] as $key => $data) { foreach ($data as $value) { if (!is_array($value)) { @@ -74,7 +66,6 @@ protected function parseData() } foreach ($value as $insertValue) { - if ($insertValue === null) { $insertValue = 'NULL'; } else { @@ -84,23 +75,22 @@ protected function parseData() } } } + foreach ($values as &$value) { - $value = "(" . implode(", ", $value) . ")"; + $value = '(' . implode(', ', $value) . ')'; } + unset($value); return ' VALUES ' . implode(', ', $values); } - /** - * @return string - */ - public function parseOnDuplicate() + public function parseOnDuplicate(): string { if ($this->hasPart('onDuplicate')) { $update = $this->getManager()->newUpdate(); $onDuplicates = $this->getPart('onDuplicate'); - $data = []; + $data = []; foreach ($onDuplicates as $onDuplicate) { foreach ($onDuplicate as $key => $value) { $data[$key] = $value; @@ -110,17 +100,18 @@ public function parseOnDuplicate() return " ON DUPLICATE KEY UPDATE {$update->parseUpdate()}"; } + return ''; } - public function setValues($values) + public function setValues(mixed $values): static { $this->_values = $values; return $this; } - public function onDuplicate($value) + public function onDuplicate(array $value): void { $this->addPart('onDuplicate', $value); } diff --git a/src/Query/Replace.php b/src/Query/Replace.php index a1f746f..3bd67c8 100644 --- a/src/Query/Replace.php +++ b/src/Query/Replace.php @@ -1,20 +1,18 @@ protect($this->getTable()) . $this->parseCols() . $this->parseValues(); - - return $query; + return 'REPLACE INTO ' . $this->protect($this->getTable()) . $this->parseCols() . $this->parseValues(); } } diff --git a/src/Query/Select.php b/src/Query/Select.php index 96d73f5..9882115 100644 --- a/src/Query/Select.php +++ b/src/Query/Select.php @@ -1,38 +1,36 @@ protect($input[0]) . ')'; + $input[0] = strtoupper($name) . '(' . $this->protect((string) $input[0]) . ')'; return $this->cols($input); } @@ -41,48 +39,36 @@ public function __call($name, $arguments) } /** - * Inserts FULLTEXT statement into $this->select and $this->where + * Add a MATCH … AGAINST full-text condition and column. * - * @param mixed $fields - * @param string $against - * @param string $alias - * @param boolean $boolean_mode - * @return $this + * @param array $fields */ - public function match($fields, $against, $alias, $boolean_mode = true) + public function match(array $fields, string $against, string $alias, bool $boolean_mode = true): static { - if (!is_array($fields)) { - $fields = []; - } - $match = []; foreach ($fields as $itemField) { if (!is_array($itemField)) { $itemField = [$itemField]; + } - $field = isset($itemField[0]) ? $itemField[0] : false; - $protected = isset($itemField[1]) ? $itemField[1] : true; + $field = $itemField[0] ?? false; + $protected = $itemField[1] ?? true; - $match[] = $protected ? $this->protect($field) : $field; - } + $match[] = $protected ? $this->protect((string) $field) : (string) $field; } - $match = 'MATCH(' . implode( - ',', - $match - ) . ") AGAINST ('" . $against . "'" . ($boolean_mode ? ' IN BOOLEAN MODE' : '') . ')'; + $match = 'MATCH(' . implode(',', $match) . ") AGAINST ('" . $against . "'" . ($boolean_mode ? ' IN BOOLEAN MODE' : '') . ')'; return $this->cols([$match, $alias, false])->where([$match]); } /** - * Inserts JOIN entry for the last table inserted by $this->from() + * Add a JOIN for the most recently added FROM table. * - * @param mixed $table the table to be joined, given as simple string or name - alias pair - * @param string|boolean $on - * @param string $type SQL join type (INNER, OUTER, LEFT INNER, etc.) - * @return $this + * @param string|array{0: string|AbstractQuery, 1?: string} $table Table name or [table, alias] + * @param string|array{0: string, 1: string}|false $on ON condition + * @param string $type JOIN type (LEFT, RIGHT, INNER, …) */ - public function join($table, $on = false, $type = '') + public function join(mixed $table, mixed $on = false, string $type = ''): static { $lastTable = end($this->parts['from']); @@ -100,13 +86,11 @@ public function join($table, $on = false, $type = '') } /** - * Sets the group paramater for the query + * Set the GROUP BY clause. * - * @param array $fields - * @param boolean $rollup suport for modifier WITH ROLLUP - * @return $this + * @param array|string $fields */ - public function group($fields, $rollup = false) + public function group(mixed $fields, bool $rollup = false): static { $this->parts['group']['fields'] = $fields; $this->parts['group']['rollup'] = $rollup; @@ -114,46 +98,41 @@ public function group($fields, $rollup = false) return $this; } - /** - * @return string - */ - public function assemble() + public function assemble(): string { - $select = $this->parseCols(); + $select = $this->parseCols(); $options = $this->parseOptions(); - $from = $this->parseFrom(); - - $group = $this->parseGroup(); - $having = $this->parseHaving(); - - $order = $this->parseOrder(); + $from = $this->parseFrom(); + $group = $this->parseGroup(); + $having = $this->parseHaving(); + $order = $this->parseOrder(); - $query = "SELECT"; + $query = 'SELECT'; if (!empty($options)) { - $query .= " $options"; + $query .= " {$options}"; } if (!empty($select)) { - $query .= " $select"; + $query .= " {$select}"; } if (!empty($from)) { - $query .= " FROM $from"; + $query .= " FROM {$from}"; } $query .= $this->assembleWhere(); if (!empty($group)) { - $query .= " GROUP BY $group"; + $query .= " GROUP BY {$group}"; } if (!empty($having)) { - $query .= " HAVING $having"; + $query .= " HAVING {$having}"; } if (!empty($order)) { - $query .= " ORDER BY $order"; + $query .= " ORDER BY {$order}"; } $query .= $this->assembleLimit(); @@ -161,137 +140,119 @@ public function assemble() return $query; } - /** - * @return null|string - */ - public function parseOptions() + public function parseOptions(): ?string { if (!empty($this->parts['options'])) { - return implode(" ", array_map("strtoupper", $this->parts['options'])); + return implode(' ', array_map('strtoupper', $this->parts['options'])); } return null; } - /** - * @param $query - * @return Union - */ - public function union($query) + public function union(AbstractQuery $query): Union { return new Union($this, $query); } - /** - * Parses SELECT entries - * - * @return string - */ - protected function parseCols() + protected function parseCols(): string { if (!isset($this->parts['cols']) || !is_array($this->parts['cols']) || count($this->parts['cols']) < 1) { return '*'; - } else { - $selectParts = []; + } - foreach ($this->parts['cols'] as $itemSelect) { - if (is_array($itemSelect)) { - $field = isset($itemSelect[0]) ? $itemSelect[0] : false; - $alias = isset($itemSelect[1]) ? $itemSelect[1] : false; - $protected = isset($itemSelect[2]) ? $itemSelect[2] : true; + $selectParts = []; - $selectParts[] = ($protected ? $this->protect($field) : $field) . (!empty($alias) ? ' AS ' . $this->protect($alias) : ''); - } else { - $selectParts[] = $itemSelect; - } - } + foreach ($this->parts['cols'] as $itemSelect) { + if (is_array($itemSelect)) { + $field = $itemSelect[0] ?? false; + $alias = $itemSelect[1] ?? false; + $protected = $itemSelect[2] ?? true; - return implode(', ', $selectParts); + $selectParts[] = ($protected ? $this->protect((string) $field) : (string) $field) + . (!empty($alias) ? ' AS ' . $this->protect((string) $alias) : ''); + } else { + $selectParts[] = $itemSelect; + } } + + return implode(', ', $selectParts); } - /** - * Parses FROM entries - * @return string - */ - private function parseFrom() + private function parseFrom(): string { - if (!empty($this->parts['from'])) { - $parts = []; - - foreach ($this->parts['from'] as $key => $item) { - if (is_array($item)) { - $table = isset($item[0]) ? $item[0] : false; - $alias = isset($item[1]) ? $item[1] : false; - - if (is_object($table)) { - if (!$alias) { - trigger_error('Select statements in for need aliases defined', E_USER_ERROR); - } - $parts[$key] = '(' . $table . ') AS ' . $this->protect($alias) . $this->parseJoin($alias); - } else { - $parts[$key] = $this->protect($table) . ' AS ' . $this->protect((!empty($alias) ? $alias : $table)) . $this->parseJoin($alias); + if (empty($this->parts['from'])) { + return ''; + } + + $parts = []; + + foreach ($this->parts['from'] as $key => $item) { + if (is_array($item)) { + $table = $item[0] ?? false; + $alias = $item[1] ?? false; + + if (is_object($table)) { + if (!$alias) { + trigger_error('Select statements in FROM need aliases defined', E_USER_ERROR); } - } elseif (!strpos($item, ' ')) { - $parts[] = $this->protect($item) . $this->parseJoin($item); + $parts[$key] = '(' . $table . ') AS ' . $this->protect((string) $alias) . $this->parseJoin((string) $alias); } else { - $parts[] = $item; + $parts[$key] = $this->protect((string) $table) + . ' AS ' . $this->protect((string) (!empty($alias) ? $alias : $table)) + . $this->parseJoin((string) (!empty($alias) ? $alias : $table)); } + } elseif (!str_contains((string) $item, ' ')) { + $parts[] = $this->protect((string) $item) . $this->parseJoin((string) $item); + } else { + $parts[] = (string) $item; } - - return implode(", ", array_unique($parts)); } - return null; + return implode(', ', array_unique($parts)); } - /** - * Parses JOIN entries for a given table - * Concatenates $this->join entries for input table - * - * @param string $table table to build JOIN statement for - * @return string - */ - private function parseJoin($table) + private function parseJoin(string $table): string { $result = ''; - if (isset($this->parts['join'][$table])) { - foreach ($this->parts['join'][$table] as $join) { - if (!is_array($join[0])) { - $join[0] = [$join[0]]; - } + if (!isset($this->parts['join'][$table])) { + return $result; + } - $joinTable = isset($join[0][0]) ? $join[0][0] : false; - $joinAlias = isset($join[0][1]) ? $join[0][1] : false; - $joinOn = isset($join[1]) ? $join[1] : false; + foreach ($this->parts['join'][$table] as $join) { + if (!is_array($join[0])) { + $join[0] = [$join[0]]; + } + $joinTable = $join[0][0] ?? false; + $joinAlias = $join[0][1] ?? false; + $joinOn = $join[1] ?? false; + $joinType = $join[2] ?? ''; - $joinType = isset($join[2]) ? $join[2] : ''; + $result .= ($joinType ? ' ' . strtoupper((string) $joinType) : '') . ' JOIN '; - $result .= ($joinType ? ' ' . strtoupper($joinType) : '') . ' JOIN '; - if ($joinTable instanceof AbstractQuery) { - $result .= '(' . $joinTable . ')'; - if (empty($joinAlias)) { - $joinAlias = 'join1'; - } - $joinTable = $joinAlias; - } elseif (strpos($joinTable, '(') !== false) { - $result .= $joinTable; - } else { - $result .= $this->protect($joinTable); + if ($joinTable instanceof AbstractQuery) { + $result .= '(' . $joinTable . ')'; + if (empty($joinAlias)) { + $joinAlias = 'join1'; } - $result .= (!empty($joinAlias) ? ' AS ' . $this->protect($joinAlias) : ''); - - if ($joinOn) { - $result .= ' ON '; - if (is_array($joinOn)) { - $result .= $this->protect($table . '.' . $joinOn[0]) - . ' = ' - . $this->protect($joinTable . '.' . $joinOn[1]); - } else { - $result .= '(' . $joinOn . ')'; - } + $joinTable = $joinAlias; + } elseif (str_contains((string) $joinTable, '(')) { + $result .= (string) $joinTable; + } else { + $result .= $this->protect((string) $joinTable); + } + + $result .= (!empty($joinAlias) ? ' AS ' . $this->protect((string) $joinAlias) : ''); + + if ($joinOn) { + $result .= ' ON '; + if (is_array($joinOn)) { + $result .= $this->protect($table . '.' . $joinOn[0]) + . ' = ' + . $this->protect($joinTable . '.' . $joinOn[1]); + } else { + $result .= '(' . $joinOn . ')'; } } } @@ -299,29 +260,24 @@ private function parseJoin($table) return $result; } - /** - * Parses GROUP entries - * - * @uses $this->group['fields'] array with elements to group by - * @return string - */ - private function parseGroup() + private function parseGroup(): string { $group = ''; + if (isset($this->parts['group']['fields'])) { if (is_array($this->parts['group']['fields'])) { $groupFields = []; foreach ($this->parts['group']['fields'] as $field) { - $field = is_array($field) ? $field : [$field]; - $column = isset($field[0]) ? $field[0] : false; - $type = isset($field[1]) ? $field[1] : ''; + $field = is_array($field) ? $field : [$field]; + $column = $field[0] ?? false; + $type = $field[1] ?? ''; - $groupFields[] = $this->protect($column) . ($type ? ' ' . strtoupper($type) : ''); + $groupFields[] = $this->protect((string) $column) . ($type ? ' ' . strtoupper((string) $type) : ''); } $group .= implode(', ', $groupFields); } else { - $group .= $this->parts['group']['fields']; + $group .= (string) $this->parts['group']['fields']; } } diff --git a/src/Query/Truncate.php b/src/Query/Truncate.php index d428ad5..d099518 100644 --- a/src/Query/Truncate.php +++ b/src/Query/Truncate.php @@ -1,18 +1,18 @@ getTable(); + return 'TRUNCATE TABLE ' . $this->protect($this->getTable()); } } diff --git a/src/Query/Update.php b/src/Query/Update.php index f7b629c..1c217f2 100644 --- a/src/Query/Update.php +++ b/src/Query/Update.php @@ -1,47 +1,45 @@ protect($this->getTable()) . ' SET ' . $this->parseUpdate(); - + $query = 'UPDATE ' . $this->protect($this->getTable()) . ' SET ' . $this->parseUpdate(); $query .= $this->assembleWhere(); $query .= $this->assembleLimit(); return $query; } - /** - * @return bool|string - */ - public function parseUpdate() + public function parseUpdate(): string { - if (!$this->parts['data']) { - return false; + if (empty($this->parts['data'])) { + return ''; } + $fields = []; + foreach ($this->parts['data'] as $data) { foreach ($data as $key => $values) { if (!is_array($values)) { $values = [$values]; } $value = $values[0]; - $quote = isset($values[1]) ? $values[1] : null; + $quote = $values[1] ?? null; if ($value === null) { $value = 'NULL'; } elseif (!is_numeric($value)) { - if (is_null($quote)) { + if ($quote === null) { $quote = true; } if ($quote) { @@ -49,10 +47,10 @@ public function parseUpdate() } } - $fields[] = "{$this->protect($key)} = $value"; + $fields[] = "{$this->protect((string) $key)} = {$value}"; } } - return implode(", ", $fields); + return implode(', ', $fields); } } diff --git a/src/Query/select/Union.php b/src/Query/select/Union.php index 5d4c127..52737de 100644 --- a/src/Query/select/Union.php +++ b/src/Query/select/Union.php @@ -1,30 +1,36 @@ _query1 = $query1; $this->_query2 = $query2; } - public function assemble() + public function assemble(): string { - $query = ($this->_query1 instanceof Union) ? "(" . $this->_query1 . ")" : $this->_query1; - $query .= " UNION "; - $query .= ($this->_query2 instanceof Union) ? "(" . $this->_query2 . ")" : $this->_query2; + $query = ($this->_query1 instanceof Union) ? '(' . $this->_query1 . ')' : (string) $this->_query1; + $query .= ' UNION '; + $query .= ($this->_query2 instanceof Union) ? '(' . $this->_query2 . ')' : (string) $this->_query2; $order = $this->parseOrder(); - if (!empty($order)) { - $query .= " ORDER BY $order"; + $query .= " ORDER BY {$order}"; } if (!empty($this->parts['limit'])) { diff --git a/src/Result.php b/src/Result.php index 8180acb..f03b4b7 100644 --- a/src/Result.php +++ b/src/Result.php @@ -1,5 +1,7 @@ > */ + protected array $results = []; /** - * Result constructor. - * - * @param \mysqli_result $resultSQL + * @param mixed $resultSQL Raw driver result (e.g. \mysqli_result or bool) * @param AbstractAdapter $adapter */ - public function __construct($resultSQL, $adapter) + public function __construct(mixed $resultSQL, AbstractAdapter $adapter) { $this->resultSQL = $resultSQL; - $this->adapter = $adapter; + $this->adapter = $adapter; } public function __destruct() @@ -48,30 +41,24 @@ public function __destruct() } } - /** - * @return AbstractAdapter|MySQLi - */ - public function getAdapter() + public function getAdapter(): AbstractAdapter { return $this->adapter; } - /** - * @param AbstractAdapter|MySQLi $adapter - */ - public function setAdapter($adapter) + public function setAdapter(AbstractAdapter $adapter): void { $this->adapter = $adapter; } /** - * Fetches all rows from current result set. + * Fetch and cache all rows from the current result set. * - * @return array + * @return list> */ - public function fetchResults() + public function fetchResults(): array { - if (count($this->results) == 0) { + if (count($this->results) === 0) { while ($result = $this->fetchResult()) { $this->results[] = $result; } @@ -81,15 +68,15 @@ public function fetchResults() } /** - * Fetches row from current result set. + * Fetch the next row as an associative array. * - * @return bool|array + * @return array|false */ - public function fetchResult() + public function fetchResult(): array|false { if ($this->checkValid()) { try { - return $this->getAdapter()->fetchAssoc($this->resultSQL); + return $this->getAdapter()->fetchAssoc($this->resultSQL) ?? false; } catch (Exception $e) { $e->log(); } @@ -98,48 +85,35 @@ public function fetchResult() return false; } - /** - * @return bool - */ - public function checkValid() + public function checkValid(): bool { if (!$this->isValid()) { - trigger_error("Invalid result for query [" . $this->getQuery()->getString() . "]", E_USER_WARNING); - + trigger_error( + 'Invalid result for query [' . ($this->query?->getString() ?? '') . ']', + E_USER_WARNING + ); return false; } return true; } - /** - * @return bool - */ - public function isValid() + public function isValid(): bool { return $this->resultSQL !== false && $this->resultSQL !== null; } - /** - * @return AbstractQuery - */ - public function getQuery() + public function getQuery(): ?AbstractQuery { return $this->query; } - /** - * @param AbstractQuery $query - */ - public function setQuery($query) + public function setQuery(AbstractQuery $query): void { $this->query = $query; } - /** - * @return bool|int - */ - public function numRows() + public function numRows(): int|false { if ($this->checkValid()) { return $this->getAdapter()->numRows($this->resultSQL); @@ -148,3 +122,4 @@ public function numRows() return false; } } + diff --git a/tests/Query/PreparedStatementTest.php b/tests/Query/PreparedStatementTest.php new file mode 100644 index 0000000..b25af66 --- /dev/null +++ b/tests/Query/PreparedStatementTest.php @@ -0,0 +1,164 @@ +query->from('users')->where('id = ?', 42); + + self::assertSame('SELECT * FROM `users` WHERE id = ?', $this->query->getParameterizedSql()); + self::assertSame([42], $this->query->getBindings()); + } + + public function test_multiple_scalar_placeholders_via_and(): void + { + $this->query->from('users') + ->where('id = ?', 42) + ->where('active = ?', 1); + + self::assertSame('SELECT * FROM `users` WHERE id = ? AND active = ?', $this->query->getParameterizedSql()); + self::assertSame([42, 1], $this->query->getBindings()); + } + + public function test_or_where_bindings(): void + { + $this->query->from('users') + ->where('id = ?', 1) + ->orWhere('id = ?', 2); + + self::assertSame('SELECT * FROM `users` WHERE id = ? OR id = ?', $this->query->getParameterizedSql()); + self::assertSame([1, 2], $this->query->getBindings()); + } + + public function test_array_in_clause_expands_placeholders(): void + { + $this->query->from('users')->where('id IN ?', [10, 20, 30]); + + self::assertSame('SELECT * FROM `users` WHERE id IN (?, ?, ?)', $this->query->getParameterizedSql()); + self::assertSame([10, 20, 30], $this->query->getBindings()); + } + + public function test_no_placeholders_returns_empty_bindings(): void + { + $this->query->from('users')->where('deleted_at IS NULL'); + + self::assertSame('SELECT * FROM `users` WHERE deleted_at IS NULL', $this->query->getParameterizedSql()); + self::assertSame([], $this->query->getBindings()); + } + + public function test_no_where_clause(): void + { + $this->query->from('users'); + + self::assertSame('SELECT * FROM `users`', $this->query->getParameterizedSql()); + self::assertSame([], $this->query->getBindings()); + } + + // ------------------------------------------------------------------------- + // toSql + // ------------------------------------------------------------------------- + + public function test_to_sql_returns_tuple(): void + { + $this->query->from('users')->where('email = ?', 'alice@example.com'); + + [$sql, $bindings] = $this->query->toSql(); + + self::assertSame('SELECT * FROM `users` WHERE email = ?', $sql); + self::assertSame(['alice@example.com'], $bindings); + } + + // ------------------------------------------------------------------------- + // Verify that getString() (legacy path) is NOT affected + // ------------------------------------------------------------------------- + + public function test_getString_still_interpolates(): void + { + $this->query->from('users')->where('id = ?', 5); + + // The legacy path must still inline the value. + self::assertSame('SELECT * FROM `users` WHERE id = 5', $this->query->getString()); + + // And toSql must NOT be changed by the getString() call. + [$sql, $bindings] = $this->query->toSql(); + self::assertSame('SELECT * FROM `users` WHERE id = ?', $sql); + self::assertSame([5], $bindings); + } + + public function test_getString_cache_is_preserved_after_toSql(): void + { + $this->query->from('users')->where('id = ?', 7); + + // Prime the getString() cache. + $first = $this->query->getString(); + + // toSql() must not corrupt the cache. + $this->query->toSql(); + + self::assertSame($first, $this->query->getString()); + } + + // ------------------------------------------------------------------------- + // Nested sub-query bindings + // ------------------------------------------------------------------------- + + public function test_nested_subquery_bindings_are_merged(): void + { + $inner = new Select(); + $inner->setManager($this->connection); + $inner->from('orders')->where('user_id = ?', 99); + + $this->query->from('users')->where('id NOT IN ?', $inner); + + [$sql, $bindings] = $this->query->toSql(); + + self::assertSame( + 'SELECT * FROM `users` WHERE id NOT IN (SELECT * FROM `orders` WHERE user_id = ?)', + $sql + ); + self::assertSame([99], $bindings); + } + + // ------------------------------------------------------------------------- + // setUp + // ------------------------------------------------------------------------- + + protected function setUp(): void + { + parent::setUp(); + + $adapterMock = m::mock(MySQLi::class)->makePartial(); + $adapterMock->shouldReceive('cleanData')->andReturnUsing(fn($d) => $d); + + $this->connection = new Connection(false); + $this->connection->setAdapter($adapterMock); + + $this->query = new Select(); + $this->query->setManager($this->connection); + } +}