Repository: codeigniter4/CodeIgniter4 (PHP)

Issue:
Bug: [QueryBuilder] select() TypeError: trim(): Argument #1 ($string) must be of type string, CodeIgniter\Database\RawSql given
### PHP Version

8.1

### CodeIgniter4 Version

4.5.3

### CodeIgniter4 Installation Method

Composer (as dependency to an existing project)

### Which operating systems have you tested for this bug?

Windows, Linux

### Which server did you use?

apache

### Database

MySQL (ver. 8.3.0)

### What happened?

I encountered a TypeError when running a test that involves selecting columns using RawSql in combination with string column names. The error message indicates that the `trim()` function is receiving a `CodeIgniter\Database\RawSql` object instead of a string.

```
TypeError: trim(): Argument #1 ($string) must be of type string, CodeIgniter\Database\RawSql given

/vendor/codeigniter4/framework/system/Database/BaseBuilder.php:415
/vendor/codeigniter4/framework/system/Model.php:930
```

### Steps to Reproduce

```php

$query = model(Model::class)
->select([
    new RawSql('col1'),
    'col2'
])->builder()->getCompiledSelect();

print_r($query);
```

### Expected Output

```sql
SELECT `col1`, `col2`
FROM `table`
```

### Anything else?

If only `RawSql` objects are passed to the select method, it works fine. However, when combining `RawSql` objects with strings, the error occurs.

I temporarily resolved the issue by chaining the select method calls separately for RawSql and string columns: 
```php

$query = model(Model::class)
->select(new RawSql('col1'))
->select('col2')
->builder()->getCompiledSelect();

print_r($query);
```


Discussion:
Expected Output
```sql
SELECT col1, `col2`
FROM `table`
```
Why do you want to use RawSql to only the first element?

Check this. #9009
> Expected Output
> 
> ```sql
> SELECT col1, `col2`
> FROM `table`
> ```
> 
> Why do you want to use RawSql to only the first element?

I have developed a custom library that dynamically constructs queries and passes the columns to the select method. Depending on the complexity of the query, this library automatically determines whether to return an instance of RawSql or a string. This approach is necessary to handle complex SQL scenarios where parts of the query may need to be constructed using raw SQL expressions.
> Check this. #9009

@kenjis 

Still not fixed

Try to reverse the order in tests :

```php
        $builder->select([
            new RawSql("IF(salary > 5000, 'High', 'Low') AS salary_level"),
            'employee_id',
        ]);

```

I think the problem is the method: `BaseBuilder::compileSelect()`



Patch:
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<bool|null> 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>|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);


Test patch:
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<RawSql|string> $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<list<RawSql|string>|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);