diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 5a39d840a54d..bd665e22f5f7 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -1819,12 +1819,6 @@ 'count' => 1, 'path' => __DIR__ . '/system/Database/BaseBuilder.php', ]; -$ignoreErrors[] = [ - // identifier: missingType.iterableValue - 'message' => '#^Method CodeIgniter\\\\Database\\\\BaseBuilder\\:\\:select\\(\\) has parameter \\$select with no value type specified in iterable type array\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Database/BaseBuilder.php', -]; $ignoreErrors[] = [ // identifier: missingType.iterableValue 'message' => '#^Method CodeIgniter\\\\Database\\\\BaseBuilder\\:\\:set\\(\\) has parameter \\$key with no value type specified in iterable type array\\.$#', @@ -1993,12 +1987,6 @@ 'count' => 1, 'path' => __DIR__ . '/system/Database/BaseBuilder.php', ]; -$ignoreErrors[] = [ - // identifier: missingType.iterableValue - 'message' => '#^Property CodeIgniter\\\\Database\\\\BaseBuilder\\:\\:\\$QBNoEscape type has no value type specified in iterable type array\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Database/BaseBuilder.php', -]; $ignoreErrors[] = [ // identifier: missingType.iterableValue 'message' => '#^Property CodeIgniter\\\\Database\\\\BaseBuilder\\:\\:\\$QBOptions type has no value type specified in iterable type array\\.$#', diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 4e6e422b2a6f..4d153fca1dc1 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -124,9 +124,9 @@ class BaseBuilder protected array $QBUnion = []; /** - * QB NO ESCAPE data + * Whether to protect identifiers in SELECT * - * @var array + * @var list true=protect, false=not protect */ public $QBNoEscape = []; @@ -390,7 +390,8 @@ public function ignore(bool $ignore = true) /** * Generates the SELECT portion of the query * - * @param array|RawSql|string $select + * @param list|RawSql|string $select + * @param bool|null $escape Whether to protect identifiers * * @return $this */ @@ -402,16 +403,21 @@ public function select($select = '*', ?bool $escape = null) } if ($select instanceof RawSql) { - $this->QBSelect[] = $select; - - return $this; + $select = [$select]; } if (is_string($select)) { - $select = $escape === false ? [$select] : explode(',', $select); + $select = ($escape === false) ? [$select] : explode(',', $select); } foreach ($select as $val) { + if ($val instanceof RawSql) { + $this->QBSelect[] = $val; + $this->QBNoEscape[] = false; + + continue; + } + $val = trim($val); if ($val !== '') { @@ -3054,15 +3060,17 @@ protected function compileSelect($selectOverride = false): string if (empty($this->QBSelect)) { $sql .= '*'; - } elseif ($this->QBSelect[0] instanceof RawSql) { - $sql .= (string) $this->QBSelect[0]; } else { // Cycle through the "select" portion of the query and prep each column name. // The reason we protect identifiers here rather than in the select() function // is because until the user calls the from() function we don't know if there are aliases foreach ($this->QBSelect as $key => $val) { - $noEscape = $this->QBNoEscape[$key] ?? null; - $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $noEscape); + if ($val instanceof RawSql) { + $this->QBSelect[$key] = (string) $val; + } else { + $protect = $this->QBNoEscape[$key] ?? null; + $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect); + } } $sql .= implode(', ', $this->QBSelect); diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php index cd3f39029f6b..fe4ad5b80178 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -67,6 +68,65 @@ public function testSelectAcceptsArray(): void $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); } + /** + * @param list $select + */ + #[DataProvider('provideSelectAcceptsArrayWithRawSql')] + public function testSelectAcceptsArrayWithRawSql(array $select, string $expected): void + { + $builder = new BaseBuilder('employees', $this->db); + + $builder->select($select); + + $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + /** + * @return list|string> + */ + public static function provideSelectAcceptsArrayWithRawSql(): iterable + { + yield from [ + [ + [ + new RawSql("IF(salary > 5000, 'High', 'Low') AS salary_level"), + 'employee_id', + ], + <<<'SQL' + SELECT IF(salary > 5000, 'High', 'Low') AS salary_level, "employee_id" FROM "employees" + SQL, + ], + [ + [ + 'employee_id', + new RawSql("IF(salary > 5000, 'High', 'Low') AS salary_level"), + ], + <<<'SQL' + SELECT "employee_id", IF(salary > 5000, 'High', 'Low') AS salary_level FROM "employees" + SQL, + ], + [ + [ + new RawSql("CONCAT(first_name, ' ', last_name) AS full_name"), + new RawSql("IF(salary > 5000, 'High', 'Low') AS salary_level"), + ], + <<<'SQL' + SELECT CONCAT(first_name, ' ', last_name) AS full_name, IF(salary > 5000, 'High', 'Low') AS salary_level FROM "employees" + SQL, + ], + [ + [ + new RawSql("CONCAT(first_name, ' ', last_name) AS full_name"), + 'employee_id', + new RawSql("IF(salary > 5000, 'High', 'Low') AS salary_level"), + ], + <<<'SQL' + SELECT CONCAT(first_name, ' ', last_name) AS full_name, "employee_id", IF(salary > 5000, 'High', 'Low') AS salary_level FROM "employees" + SQL, + ], + ]; + } + public function testSelectAcceptsMultipleColumns(): void { $builder = new BaseBuilder('users', $this->db);