diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 41ccc064bed9..24c8799e64c2 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -2137,12 +2137,6 @@ 'count' => 1, 'path' => __DIR__ . '/system/Database/BaseConnection.php', ]; -$ignoreErrors[] = [ - // identifier: missingType.iterableValue - 'message' => '#^Property CodeIgniter\\\\Database\\\\BaseConnection\\:\\:\\$aliasedTables type has no value type specified in iterable type array\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Database/BaseConnection.php', -]; $ignoreErrors[] = [ // identifier: missingType.iterableValue 'message' => '#^Property CodeIgniter\\\\Database\\\\BaseConnection\\:\\:\\$dataCache type has no value type specified in iterable type array\\.$#', diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php index a413d2e39ff8..954cf33aaa1f 100644 --- a/system/Commands/Database/ShowTableInfo.php +++ b/system/Commands/Database/ShowTableInfo.php @@ -16,6 +16,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; @@ -199,7 +200,7 @@ private function showDataOfTable(string $tableName, int $limitRows, int $limitFi CLI::newLine(); $this->removeDBPrefix(); - $thead = $this->db->getFieldNames($tableName); + $thead = $this->db->getFieldNames(TableName::fromActualName($this->db, $tableName)); $this->restoreDBPrefix(); // If there is a field named `id`, sort by it. @@ -277,7 +278,7 @@ private function makeTableRows( $this->tbody = []; $this->removeDBPrefix(); - $builder = $this->db->table($tableName); + $builder = $this->db->table(TableName::fromActualName($this->db, $tableName)); $builder->limit($limitRows); if ($sortField !== null) { $builder->orderBy($sortField, $this->sortDesc ? 'DESC' : 'ASC'); diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 26a6de0a6d21..00a6dcdf5807 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -298,7 +298,7 @@ class BaseBuilder /** * Constructor * - * @param array|string $tableName tablename or tablenames with or without aliases + * @param array|string|TableName $tableName tablename or tablenames with or without aliases * * Examples of $tableName: `mytable`, `jobs j`, `jobs j, users u`, `['jobs j','users u']` * @@ -315,15 +315,20 @@ public function __construct($tableName, ConnectionInterface $db, ?array $options */ $this->db = $db; + if ($tableName instanceof TableName) { + $this->tableName = $tableName->getTableName(); + $this->QBFrom[] = $this->db->escapeIdentifier($tableName); + $this->db->addTableAlias($tableName->getAlias()); + } // If it contains `,`, it has multiple tables - if (is_string($tableName) && ! str_contains($tableName, ',')) { + elseif (is_string($tableName) && ! str_contains($tableName, ',')) { $this->tableName = $tableName; // @TODO remove alias if exists + $this->from($tableName); } else { $this->tableName = ''; + $this->from($tableName); } - $this->from($tableName); - if ($options !== null && $options !== []) { foreach ($options as $key => $value) { if (property_exists($this, $key)) { @@ -3038,10 +3043,10 @@ protected function trackAliases($table) $table = preg_replace('/\s+AS\s+/i', ' ', $table); // Grab the alias - $table = trim(strrchr($table, ' ')); + $alias = trim(strrchr($table, ' ')); // Store the alias, if it doesn't already exist - $this->db->addTableAlias($table); + $this->db->addTableAlias($alias); } } diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index e918f10aaea2..79e7f9159f79 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -340,7 +340,7 @@ abstract class BaseConnection implements ConnectionInterface /** * Array of table aliases. * - * @var array + * @var list */ protected $aliasedTables = []; @@ -576,10 +576,14 @@ public function setAliasedTables(array $aliases) * * @return $this */ - public function addTableAlias(string $table) + public function addTableAlias(string $alias) { - if (! in_array($table, $this->aliasedTables, true)) { - $this->aliasedTables[] = $table; + if ($alias === '') { + return $this; + } + + if (! in_array($alias, $this->aliasedTables, true)) { + $this->aliasedTables[] = $alias; } return $this; @@ -925,7 +929,7 @@ abstract protected function _transRollback(): bool; /** * Returns a non-shared new instance of the query builder for this connection. * - * @param array|string $tableName + * @param array|string|TableName $tableName * * @return BaseBuilder * @@ -1054,10 +1058,10 @@ public function getConnectDuration(int $decimals = 6): string * insert the table prefix (if it exists) in the proper position, and escape only * the correct identifiers. * - * @param array|int|string $item - * @param bool $prefixSingle Prefix a table name with no segments? - * @param bool $protectIdentifiers Protect table or column names? - * @param bool $fieldExists Supplied $item contains a column name? + * @param array|int|string|TableName $item + * @param bool $prefixSingle Prefix a table name with no segments? + * @param bool $protectIdentifiers Protect table or column names? + * @param bool $fieldExists Supplied $item contains a column name? * * @return array|string * @phpstan-return ($item is array ? array : string) @@ -1078,6 +1082,11 @@ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $pro return $escapedArray; } + if ($item instanceof TableName) { + /** @psalm-suppress NoValue I don't know why ERROR. */ + return $this->escapeTableName($item); + } + // If you pass `['column1', 'column2']`, `$item` will be int because the array keys are int. $item = (string) $item; @@ -1220,10 +1229,18 @@ private function protectDotItem(string $item, string $alias, bool $protectIdenti * * This function escapes single identifier. * - * @param non-empty-string $item + * @param non-empty-string|TableName $item */ - public function escapeIdentifier(string $item): string + public function escapeIdentifier($item): string { + if ($item === '') { + return ''; + } + + if ($item instanceof TableName) { + return $this->escapeTableName($item); + } + return $this->escapeChar . str_replace( $this->escapeChar, @@ -1233,6 +1250,17 @@ public function escapeIdentifier(string $item): string . $this->escapeChar; } + /** + * Returns escaped table name with alias. + */ + private function escapeTableName(TableName $tableName): string + { + $alias = $tableName->getAlias(); + + return $this->escapeIdentifier($tableName->getActualTableName()) + . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : ''); + } + /** * Escape the SQL Identifiers * @@ -1546,12 +1574,16 @@ public function tableExists(string $tableName, bool $cached = true): bool /** * Fetch Field Names * + * @param string|TableName $tableName + * * @return false|list * * @throws DatabaseException */ - public function getFieldNames(string $table) + public function getFieldNames($tableName) { + $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName; + // Is there a cached result? if (isset($this->dataCache['field_names'][$table])) { return $this->dataCache['field_names'][$table]; @@ -1561,7 +1593,7 @@ public function getFieldNames(string $table) $this->initialize(); } - if (false === ($sql = $this->_listColumns($table))) { + if (false === ($sql = $this->_listColumns($tableName))) { if ($this->DBDebug) { throw new DatabaseException('This feature is not available for the database you are using.'); } @@ -1777,9 +1809,11 @@ abstract protected function _listTables(bool $constrainByPrefix = false, ?string /** * Generates a platform-specific query string so that the column names can be fetched. * + * @param string|TableName $table + * * @return false|string */ - abstract protected function _listColumns(string $table = ''); + abstract protected function _listColumns($table = ''); /** * Platform-specific field data information. diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index a0d02ccbfb4d..b20422d90aad 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\LogicException; use mysqli; use mysqli_result; @@ -422,10 +423,19 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n /** * Generates a platform-specific query string so that the column names can be fetched. + * + * @param string|TableName $table */ - protected function _listColumns(string $table = ''): string + protected function _listColumns($table = ''): string { - return 'SHOW COLUMNS FROM ' . $this->protectIdentifiers($table, true, null, false); + $tableName = $this->protectIdentifiers( + $table, + true, + null, + false + ); + + return 'SHOW COLUMNS FROM ' . $tableName; } /** diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 2b870b755b44..e21519ea5259 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -16,6 +16,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Query; +use CodeIgniter\Database\TableName; use ErrorException; use stdClass; @@ -284,18 +285,25 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n /** * Generates a platform-specific query string so that the column names can be fetched. + * + * @param string|TableName $table */ - protected function _listColumns(string $table = ''): string + protected function _listColumns($table = ''): string { - if (str_contains($table, '.')) { - sscanf($table, '%[^.].%s', $owner, $table); + if ($table instanceof TableName) { + $tableName = $this->escape(strtoupper($table->getActualTableName())); + $owner = $this->username; + } elseif (str_contains($table, '.')) { + sscanf($table, '%[^.].%s', $owner, $tableName); + $tableName = $this->escape(strtoupper($this->DBPrefix . $tableName)); } else { - $owner = $this->username; + $owner = $this->username; + $tableName = $this->escape(strtoupper($this->DBPrefix . $table)); } return 'SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE UPPER(OWNER) = ' . $this->escape(strtoupper($owner)) . ' - AND UPPER(TABLE_NAME) = ' . $this->escape(strtoupper($this->DBPrefix . $table)); + AND UPPER(TABLE_NAME) = ' . $tableName; } /** diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index ec886dbd4d8b..d687e30f7def 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -16,6 +16,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\RawSql; +use CodeIgniter\Database\TableName; use ErrorException; use PgSql\Connection as PgSqlConnection; use PgSql\Result as PgSqlResult; @@ -300,13 +301,20 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n /** * Generates a platform-specific query string so that the column names can be fetched. + * + * @param string|TableName $table */ - protected function _listColumns(string $table = ''): string + protected function _listColumns($table = ''): string { + if ($table instanceof TableName) { + $tableName = $this->escape($table->getActualTableName()); + } else { + $tableName = $this->escape($this->DBPrefix . strtolower($table)); + } + return 'SELECT "column_name" FROM "information_schema"."columns" - WHERE LOWER("table_name") = ' - . $this->escape($this->DBPrefix . strtolower($table)) + WHERE LOWER("table_name") = ' . $tableName . ' ORDER BY "ordinal_position"'; } diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 7e008e27c451..2a1abf620271 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\TableName; use stdClass; /** @@ -225,12 +226,20 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n /** * Generates a platform-specific query string so that the column names can be fetched. + * + * @param string|TableName $table */ - protected function _listColumns(string $table = ''): string + protected function _listColumns($table = ''): string { + if ($table instanceof TableName) { + $tableName = $this->escape(strtolower($table->getActualTableName())); + } else { + $tableName = $this->escape($this->DBPrefix . strtolower($table)); + } + return 'SELECT [COLUMN_NAME] ' . ' FROM [INFORMATION_SCHEMA].[COLUMNS]' - . ' WHERE [TABLE_NAME] = ' . $this->escape($this->DBPrefix . $table) + . ' WHERE [TABLE_NAME] = ' . $tableName . ' AND [TABLE_SCHEMA] = ' . $this->escape($this->schema); } diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 9945d41de11b..03eb3ab130f7 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\TableName; use Exception; use SQLite3; use SQLite3Result; @@ -209,19 +210,31 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n /** * Generates a platform-specific query string so that the column names can be fetched. + * + * @param string|TableName $table */ - protected function _listColumns(string $table = ''): string + protected function _listColumns($table = ''): string { - return 'PRAGMA TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')'; + if ($table instanceof TableName) { + $tableName = $this->escapeIdentifier($table); + } else { + $tableName = $this->protectIdentifiers($table, true, null, false); + } + + return 'PRAGMA TABLE_INFO(' . $tableName . ')'; } /** + * @param string|TableName $tableName + * * @return false|list * * @throws DatabaseException */ - public function getFieldNames(string $table) + public function getFieldNames($tableName) { + $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName; + // Is there a cached result? if (isset($this->dataCache['field_names'][$table])) { return $this->dataCache['field_names'][$table]; @@ -231,7 +244,7 @@ public function getFieldNames(string $table) $this->initialize(); } - $sql = $this->_listColumns($table); + $sql = $this->_listColumns($tableName); $query = $this->query($sql); $this->dataCache['field_names'][$table] = []; diff --git a/system/Database/TableName.php b/system/Database/TableName.php new file mode 100644 index 000000000000..44f916809c52 --- /dev/null +++ b/system/Database/TableName.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +/** + * Represents a table name in SQL. + * + * @interal + * + * @see \CodeIgniter\Database\TableNameTest + */ +class TableName +{ + /** + * @param string $actualTable Actual table name + * @param string $logicalTable Logical table name (w/o DB prefix) + * @param string $schema Schema name + * @param string $database Database name + * @param string $alias Alias name + */ + protected function __construct( + private readonly string $actualTable, + private readonly string $logicalTable = '', + private readonly string $schema = '', + private readonly string $database = '', + private readonly string $alias = '' + ) { + } + + /** + * Creates a new instance. + * + * @param string $table Table name (w/o DB prefix) + * @param string $alias Alias name + */ + public static function create(BaseConnection $db, string $table, string $alias = ''): self + { + return new self( + $db->DBPrefix . $table, + $table, + '', + '', + $alias + ); + } + + /** + * Creates a new instance from an actual table name. + * + * @param string $actualTable Actual table name with DB prefix + * @param string $alias Alias name + */ + public static function fromActualName(BaseConnection $db, string $actualTable, string $alias = ''): self + { + $prefix = $db->DBPrefix; + $logicalTable = ''; + + if (str_starts_with($actualTable, $prefix)) { + $logicalTable = substr($actualTable, strlen($prefix)); + } + + return new self( + $actualTable, + $logicalTable, + '', + $alias + ); + } + + /** + * Creates a new instance from full name. + * + * @param string $table Table name (w/o DB prefix) + * @param string $schema Schema name + * @param string $database Database name + * @param string $alias Alias name + */ + public static function fromFullName( + BaseConnection $db, + string $table, + string $schema = '', + string $database = '', + string $alias = '' + ): self { + return new self( + $db->DBPrefix . $table, + $table, + $schema, + $database, + $alias + ); + } + + /** + * Returns the single segment table name w/o DB prefix. + */ + public function getTableName(): string + { + return $this->logicalTable; + } + + /** + * Returns the actual single segment table name w/z DB prefix. + */ + public function getActualTableName(): string + { + return $this->actualTable; + } + + public function getAlias(): string + { + return $this->alias; + } + + public function getSchema(): string + { + return $this->schema; + } + + public function getDatabase(): string + { + return $this->database; + } +} diff --git a/system/Test/Mock/MockConnection.php b/system/Test/Mock/MockConnection.php index ed814240bad3..7870d51b42cb 100644 --- a/system/Test/Mock/MockConnection.php +++ b/system/Test/Mock/MockConnection.php @@ -17,6 +17,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Query; +use CodeIgniter\Database\TableName; /** * @extends BaseConnection @@ -202,8 +203,10 @@ protected function _listTables(bool $constrainByPrefix = false, ?string $tableNa /** * Generates a platform-specific query string so that the column names can be fetched. + * + * @param string|TableName $table */ - protected function _listColumns(string $table = ''): string + protected function _listColumns($table = ''): string { return ''; } diff --git a/tests/system/Database/Builder/AliasTest.php b/tests/system/Database/Builder/AliasTest.php index 9528ba6aabb2..491bc282c509 100644 --- a/tests/system/Database/Builder/AliasTest.php +++ b/tests/system/Database/Builder/AliasTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Builder; +use CodeIgniter\Database\TableName; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\Group; @@ -41,6 +42,16 @@ public function testAlias(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); } + public function testTableName(): void + { + $tableName = TableName::create($this->db, 'jobs', 'j'); + $builder = $this->db->table($tableName); + + $expectedSQL = 'SELECT * FROM "jobs" "j"'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + public function testAliasSupportsArrayOfNames(): void { $builder = $this->db->table(['jobs j', 'users u']); diff --git a/tests/system/Database/TableNameTest.php b/tests/system/Database/TableNameTest.php new file mode 100644 index 000000000000..6d0e1967d81a --- /dev/null +++ b/tests/system/Database/TableNameTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class TableNameTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $this->db = new MockConnection([ + 'database' => 'test', + 'DBPrefix' => 'db_', + 'schema' => 'dbo', + ]); + } + + public function testInstantiate(): void + { + $table = 'table'; + + $tableName = TableName::create($this->db, $table); + + $this->assertInstanceOf(TableName::class, $tableName); + } + + public function testCreateAndTableName(): void + { + $table = 'table'; + + $tableName = TableName::create($this->db, $table); + + $this->assertSame($table, $tableName->getTableName()); + $this->assertSame('db_table', $tableName->getActualTableName()); + } + + public function testFromActualNameAndTableNameWithPrefix(): void + { + $actualTable = 'db_table'; + + $tableName = TableName::fromActualName($this->db, $actualTable); + + $this->assertSame('table', $tableName->getTableName()); + $this->assertSame($actualTable, $tableName->getActualTableName()); + } + + public function testFromActualNameAndTableNameWithoutPrefix(): void + { + $actualTable = 'table'; + + $tableName = TableName::fromActualName($this->db, $actualTable); + + $this->assertSame('', $tableName->getTableName()); + $this->assertSame($actualTable, $tableName->getActualTableName()); + } + + public function testGetAlias(): void + { + $table = 'table'; + $alias = 't'; + + $tableName = TableName::create($this->db, $table, $alias); + + $this->assertSame($alias, $tableName->getAlias()); + } + + public function testGetSchema(): void + { + $table = 'table'; + $schema = 'dbo'; + $database = 'test'; + + $tableName = TableName::fromFullName($this->db, $table, $schema, $database); + + $this->assertSame($schema, $tableName->getSchema()); + } + + public function testGetDatabase(): void + { + $table = 'table'; + $schema = 'dbo'; + $database = 'test'; + + $tableName = TableName::fromFullName($this->db, $table, $schema, $database); + + $this->assertSame($database, $tableName->getDatabase()); + } +} diff --git a/user_guide_src/source/changelogs/v4.6.0.rst b/user_guide_src/source/changelogs/v4.6.0.rst index 8872615a3fc6..978e7421dc47 100644 --- a/user_guide_src/source/changelogs/v4.6.0.rst +++ b/user_guide_src/source/changelogs/v4.6.0.rst @@ -84,6 +84,22 @@ Method Signature Changes - **View:** The return type of the ``renderSection()`` method has been changed to ``string``, and now the method does not call ``echo``. +Removed Type Definitions +------------------------ + +- **Database:** + - The type ``string`` of the first parameter in + ``BaseConnection::escapeIdentifier()`` has been removed. + - The type ``string`` of the first parameter in + ``BaseConnection::getFieldNames()`` and ``SQLite3\Connection::getFieldNames()`` + have been removed. + - The type ``string`` of the first parameter in + ``BaseConnection::_listColumns()`` and ``MySQLi\Connection::_listColumns()`` + and ``OCI8\Connection::_listColumns()`` + and ``Postgre\Connection::_listColumns()`` + and ``SQLSRV\Connection::_listColumns()`` + and ``SQLite3\Connection::_listColumns()`` have been removed. + .. _v460-removed-deprecated-items: Removed Deprecated Items