Repository: codeigniter4/CodeIgniter4 (PHP)

Issue:
Bug: [QueryBuilder] escaping select string
escaping a query select is not setting the proper quotes:
```php
$builder = $this->db->table('test_table')
                ->select('"numericValue1" + "numericValue2" AS "numericResult"', false)
                ->get();
echo (string)$this->db->getLastQuery();
```

that previous code return this:
```sql
SELECT numericValue1" + "numericValue2 AS "numericResult"
FROM "test_table"
```

this also happens with subselects, CASE WHEN statement and every function with ','.


Discussion:

Patch:
diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php
index f9682a72bb54..95dc96d0ca55 100644
--- a/system/Database/BaseBuilder.php
+++ b/system/Database/BaseBuilder.php
@@ -369,10 +369,6 @@ public function ignore(bool $ignore = true)
      */
     public function select($select = '*', ?bool $escape = null)
     {
-        if (is_string($select)) {
-            $select = explode(',', $select);
-        }
-
         // If the escape value was not set, we will base it on the global setting
         if (! is_bool($escape)) {
             $escape = $this->db->protectIdentifiers;
@@ -384,6 +380,10 @@ public function select($select = '*', ?bool $escape = null)
             return $this;
         }
 
+        if (is_string($select)) {
+            $select = $escape === false ? [$select] : explode(',', $select);
+        }
+
         foreach ($select as $val) {
             $val = trim($val);
 
diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php
index 4cffb5b9412f..38fc20973de0 100644
--- a/system/Database/BaseConnection.php
+++ b/system/Database/BaseConnection.php
@@ -1017,6 +1017,11 @@ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $pro
             return $item;
         }
 
+        // Do not protect identifiers and do not prefix, no swap prefix, there is nothing to do
+        if ($protectIdentifiers === false && $prefixSingle === false && $this->swapPre === '') {
+            return $item;
+        }
+
         // Convert tabs or multiple spaces into single spaces
         $item = preg_replace('/\s+/', ' ', trim($item));
 
@@ -1036,71 +1041,7 @@ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $pro
         // in the correct location, assuming the period doesn't indicate that we're dealing
         // with an alias. While we're at it, we will escape the components
         if (strpos($item, '.') !== false) {
-            $parts = explode('.', $item);
-
-            // Does the first segment of the exploded item match
-            // one of the aliases previously identified? If so,
-            // we have nothing more to do other than escape the item
-            //
-            // NOTE: The ! empty() condition prevents this method
-            // from breaking when QB isn't enabled.
-            if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
-                if ($protectIdentifiers === true) {
-                    foreach ($parts as $key => $val) {
-                        if (! in_array($val, $this->reservedIdentifiers, true)) {
-                            $parts[$key] = $this->escapeIdentifiers($val);
-                        }
-                    }
-
-                    $item = implode('.', $parts);
-                }
-
-                return $item . $alias;
-            }
-
-            // Is there a table prefix defined in the config file? If not, no need to do anything
-            if ($this->DBPrefix !== '') {
-                // We now add the table prefix based on some logic.
-                // Do we have 4 segments (hostname.database.table.column)?
-                // If so, we add the table prefix to the column name in the 3rd segment.
-                if (isset($parts[3])) {
-                    $i = 2;
-                }
-                // Do we have 3 segments (database.table.column)?
-                // If so, we add the table prefix to the column name in 2nd position
-                elseif (isset($parts[2])) {
-                    $i = 1;
-                }
-                // Do we have 2 segments (table.column)?
-                // If so, we add the table prefix to the column name in 1st segment
-                else {
-                    $i = 0;
-                }
-
-                // This flag is set when the supplied $item does not contain a field name.
-                // This can happen when this function is being called from a JOIN.
-                if ($fieldExists === false) {
-                    $i++;
-                }
-
-                // Verify table prefix and replace if necessary
-                if ($this->swapPre !== '' && strpos($parts[$i], $this->swapPre) === 0) {
-                    $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
-                }
-                // We only add the table prefix if it does not already exist
-                elseif (strpos($parts[$i], $this->DBPrefix) !== 0) {
-                    $parts[$i] = $this->DBPrefix . $parts[$i];
-                }
-
-                // Put the parts back together
-                $item = implode('.', $parts);
-            }
-
-            if ($protectIdentifiers === true) {
-                $item = $this->escapeIdentifiers($item);
-            }
-
-            return $item . $alias;
+            return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists);
         }
 
         // In some cases, especially 'from', we end up running through
@@ -1127,6 +1068,75 @@ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $pro
         return $item . $alias;
     }
 
+    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
+    {
+        $parts = explode('.', $item);
+
+        // Does the first segment of the exploded item match
+        // one of the aliases previously identified? If so,
+        // we have nothing more to do other than escape the item
+        //
+        // NOTE: The ! empty() condition prevents this method
+        // from breaking when QB isn't enabled.
+        if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
+            if ($protectIdentifiers === true) {
+                foreach ($parts as $key => $val) {
+                    if (! in_array($val, $this->reservedIdentifiers, true)) {
+                        $parts[$key] = $this->escapeIdentifiers($val);
+                    }
+                }
+
+                $item = implode('.', $parts);
+            }
+
+            return $item . $alias;
+        }
+
+        // Is there a table prefix defined in the config file? If not, no need to do anything
+        if ($this->DBPrefix !== '') {
+            // We now add the table prefix based on some logic.
+            // Do we have 4 segments (hostname.database.table.column)?
+            // If so, we add the table prefix to the column name in the 3rd segment.
+            if (isset($parts[3])) {
+                $i = 2;
+            }
+            // Do we have 3 segments (database.table.column)?
+            // If so, we add the table prefix to the column name in 2nd position
+            elseif (isset($parts[2])) {
+                $i = 1;
+            }
+            // Do we have 2 segments (table.column)?
+            // If so, we add the table prefix to the column name in 1st segment
+            else {
+                $i = 0;
+            }
+
+            // This flag is set when the supplied $item does not contain a field name.
+            // This can happen when this function is being called from a JOIN.
+            if ($fieldExists === false) {
+                $i++;
+            }
+
+            // Verify table prefix and replace if necessary
+            if ($this->swapPre !== '' && strpos($parts[$i], $this->swapPre) === 0) {
+                $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
+            }
+            // We only add the table prefix if it does not already exist
+            elseif (strpos($parts[$i], $this->DBPrefix) !== 0) {
+                $parts[$i] = $this->DBPrefix . $parts[$i];
+            }
+
+            // Put the parts back together
+            $item = implode('.', $parts);
+        }
+
+        if ($protectIdentifiers === true) {
+            $item = $this->escapeIdentifiers($item);
+        }
+
+        return $item . $alias;
+    }
+
     /**
      * Escape the SQL Identifiers
      *


Test patch:
diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php
index a4dd9d55015a..3c1be631d73e 100644
--- a/tests/system/Database/BaseConnectionTest.php
+++ b/tests/system/Database/BaseConnectionTest.php
@@ -14,6 +14,7 @@
 use CodeIgniter\Database\Exceptions\DatabaseException;
 use CodeIgniter\Test\CIUnitTestCase;
 use CodeIgniter\Test\Mock\MockConnection;
+use Generator;
 use Throwable;
 
 /**
@@ -163,4 +164,111 @@ public function testMagicGetMissing()
 
         $this->assertNull($db->foobar);
     }
+
+    /**
+     * These tests are intended to confirm the current behavior.
+     * We do not know if all of these are the correct behavior.
+     *
+     * @dataProvider identifiersProvider
+     */
+    public function testProtectIdentifiers(
+        bool $prefixSingle,
+        bool $protectIdentifiers,
+        bool $fieldExists,
+        string $item,
+        string $expected
+    ) {
+        $db = new MockConnection($this->options);
+
+        $return = $db->protectIdentifiers($item, $prefixSingle, $protectIdentifiers, $fieldExists);
+
+        $this->assertSame($expected, $return);
+    }
+
+    public function identifiersProvider(): Generator
+    {
+        yield from [
+            // $prefixSingle, $protectIdentifiers, $fieldExists, $item, $expected
+            'empty string'        => [false, true, true, '', ''],
+            'empty string prefix' => [true,  true, true, '', '"test_"'], // Incorrect usage? or should be ''?
+
+            'single table'        => [false, true, false, 'jobs', '"jobs"'],
+            'single table prefix' => [true,  true, false, 'jobs', '"test_jobs"'],
+
+            'string'        => [false, true, true, "'Accountant'", "'Accountant'"],
+            'single prefix' => [true,  true, true, "'Accountant'", "'Accountant'"],
+
+            'numbers only'        => [false, true, false, '12345', '12345'], // Should be quoted?
+            'numbers only prefix' => [true,  true, false, '12345', '"test_12345"'],
+
+            'table AS alias'        => [false, true, false, 'role AS myRole', '"role" AS "myRole"'],
+            'table AS alias prefix' => [true,  true, false, 'role AS myRole', '"test_role" AS "myRole"'],
+
+            'quoted table'        => [false, true, false, '"jobs"', '"jobs"'],
+            'quoted table prefix' => [true,  true, false, '"jobs"', '"test_jobs"'],
+
+            'quoted table alias'        => [false, true, false, '"jobs" "j"', '"jobs" "j"'],
+            'quoted table alias prefix' => [true,  true, false, '"jobs" "j"', '"test_jobs" "j"'],
+
+            'table.*'             => [false, true, true, 'jobs.*', '"test_jobs".*'], // Prefixed because it has segments
+            'table.* prefix'      => [true,  true, true, 'jobs.*', '"test_jobs".*'],
+            'table.column'        => [false, true, true, 'users.id', '"test_users"."id"'], // Prefixed because it has segments
+            'table.column prefix' => [true,  true, true, 'users.id', '"test_users"."id"'],
+            'table.column AS'     => [
+                false, true, true,
+                'users.id AS user_id',
+                '"test_users"."id" AS "user_id"', // Prefixed because it has segments
+            ],
+            'table.column AS prefix' => [
+                true,  true, true,
+                'users.id AS user_id',
+                '"test_users"."id" AS "user_id"',
+            ],
+
+            'function table.column'        => [false, true, true, 'LOWER(jobs.name)', 'LOWER(jobs.name)'],
+            'function table.column prefix' => [true,  true, true, 'LOWER(jobs.name)', 'LOWER(jobs.name)'],
+
+            'function only'   => [false, true, true, 'RAND()', 'RAND()'],
+            'function column' => [false, true, true, 'SUM(id)', 'SUM(id)'],
+
+            'function column AS' => [
+                false, true, true,
+                'COUNT(payments) AS myAlias',
+                'COUNT(payments) AS myAlias',
+            ],
+            'function column AS prefix' => [
+                true, true, true,
+                'COUNT(payments) AS myAlias',
+                'COUNT(payments) AS myAlias',
+            ],
+
+            'function quoted table column AS' => [
+                false, true, true,
+                'MAX("db"."payments") AS "payments"',
+                'MAX("db"."payments") AS "payments"',
+            ],
+
+            'quoted column operator AS' => [
+                false, true, true,
+                '"numericValue1" + "numericValue2" AS "numericResult"',
+                '"numericValue1"" + ""numericValue2" AS "numericResult"', // Cannot process correctly
+            ],
+            'quoted column operator AS no-protect' => [
+                false, false, true,
+                '"numericValue1" + "numericValue2" AS "numericResult"',
+                '"numericValue1" + "numericValue2" AS "numericResult"',
+            ],
+
+            'sub query' => [
+                false, true, true,
+                '(SELECT SUM(payments.amount) FROM payments WHERE payments.invoice_id=4) AS amount_paid)',
+                '(SELECT SUM(payments.amount) FROM payments WHERE payments.invoice_id=4) AS amount_paid)',
+            ],
+            'sub query with missing `)` at the end' => [
+                false, true, true,
+                '(SELECT MAX(advance_amount) FROM "orders" WHERE "id" > 2',
+                '(SELECT MAX(advance_amount) FROM "orders" WHERE "id" > 2',
+            ],
+        ];
+    }
 }
diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php
index 7c2c44bd246f..19dff466cf03 100644
--- a/tests/system/Database/Builder/SelectTest.php
+++ b/tests/system/Database/Builder/SelectTest.php
@@ -110,6 +110,39 @@ public function testSelectWorksWithRawSql()
         $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect()));
     }
 
+    /**
+     * @see https://github.com/codeigniter4/CodeIgniter4/issues/4311
+     */
+    public function testSelectWorksWithEscpaeFalse()
+    {
+        $builder = new BaseBuilder('users', $this->db);
+
+        $builder->select('"numericValue1" + "numericValue2" AS "numericResult"', false);
+
+        $expected = 'SELECT "numericValue1" + "numericValue2" AS "numericResult" FROM "users"';
+
+        $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect()));
+    }
+
+    /**
+     * @see https://github.com/codeigniter4/CodeIgniter4/issues/4355
+     */
+    public function testSelectRegularExpressionWorksWithEscpaeFalse()
+    {
+        $builder = new BaseBuilder('ob_human_resources', $this->db);
+
+        $builder->select(
+            'REGEXP_SUBSTR(ral_anno,"[0-9]{1,2}([,.][0-9]{1,3})([,.][0-9]{1,3})") AS ral',
+            false
+        );
+
+        $expected = <<<'SQL'
+            SELECT REGEXP_SUBSTR(ral_anno,"[0-9]{1,2}([,.][0-9]{1,3})([,.][0-9]{1,3})") AS ral
+            FROM "ob_human_resources"
+            SQL;
+        $this->assertSame($expected, $builder->getCompiledSelect());
+    }
+
     public function testSelectMinWithNoAlias()
     {
         $builder = new BaseBuilder('invoices', $this->db);