diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 29af09d0f2f6..e0595c765efa 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -2055,6 +2055,139 @@ public function get(?int $limit = null, int $offset = 0, bool $reset = true) return $result; } + /** + * Returns the value from a single column in the first row. + * + * @return mixed SQL string when test mode is enabled. + */ + public function value(string $column, bool $reset = true) + { + $result = $this->getColumnSelectResult([ + $this->prepareColumnSelect($column, 'CI_value'), + ], $reset, true); + + if (is_string($result)) { + return $result; + } + + if (! $result instanceof ResultInterface) { + return null; + } + + return $result->getRow('CI_value'); + } + + /** + * Returns the values from a single column. + * + * @return array|string SQL string when test mode is enabled. + */ + public function pluck(string $column, ?string $key = null, bool $reset = true) + { + $select = [ + $this->prepareColumnSelect($column, 'CI_value'), + ]; + + if ($key !== null) { + $select[] = $this->prepareColumnSelect($key, 'CI_key'); + } + + $result = $this->getColumnSelectResult($select, $reset); + + if (is_string($result)) { + return $result; + } + + if (! $result instanceof ResultInterface) { + return []; + } + + $values = []; + + foreach ($result->getResultArray() as $row) { + if ($key === null) { + $values[] = $row['CI_value'] ?? null; + + continue; + } + + $values[$row['CI_key'] ?? null] = $row['CI_value'] ?? null; + } + + return $values; + } + + /** + * Runs a SELECT query for temporary scalar column selection. + * + * @param list $select + * + * @return false|ResultInterface|string + */ + private function getColumnSelectResult(array $select, bool $reset, bool $limitToOne = false) + { + $qbSelect = $this->QBSelect; + $qbNoEscape = $this->QBNoEscape; + $qbSelectUsesAggregate = $this->QBSelectUsesAggregate; + $qbLimit = $this->QBLimit; + $qbOffset = $this->QBOffset; + + $this->QBSelect = []; + $this->QBNoEscape = []; + $this->QBSelectUsesAggregate = false; + + foreach ($select as $column) { + $this->select($column); + } + + if ($limitToOne && $this->QBLimit !== 0) { + $this->QBLimit = 1; + } + + try { + $result = $this->testMode + ? $this->getCompiledSelect($reset) + : $this->db->query($this->compileSelect(), $this->binds, false); + + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result; + } finally { + if (! $reset) { + $this->QBSelect = $qbSelect; + $this->QBNoEscape = $qbNoEscape; + $this->QBSelectUsesAggregate = $qbSelectUsesAggregate; + $this->QBLimit = $qbLimit; + $this->QBOffset = $qbOffset; + } + } + } + + /** + * Prepares a single column for scalar selection. + * + * @throws DataException + */ + private function prepareColumnSelect(string $column, string $alias): string + { + $column = trim($column); + + if ($column === '') { + throw DataException::forEmptyInputGiven('Select'); + } + + if (preg_match('/(^|\\.)\\*$|[\\s(),]/', $column) === 1) { + throw DataException::forInvalidArgument('column name'); + } + + return $column . ' AS ' . $alias; + } + /** * Explains the select statement based on the other functions called * and runs the query. diff --git a/system/Model.php b/system/Model.php index f6b125260398..00143a4dda45 100644 --- a/system/Model.php +++ b/system/Model.php @@ -534,6 +534,30 @@ public function countAllResults(bool $reset = true, bool $test = false) return $this->builder()->testMode($test)->countAllResults($reset); } + /** + * Returns the value from a single column in the first row. + * + * @return mixed Returns a SQL string if in test mode. + */ + public function value(string $column, bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->value($column, $reset); + } + + /** + * Returns the values from a single column. + * + * @return array|string Returns a SQL string if in test mode. + */ + public function pluck(string $column, ?string $key = null, bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->pluck($column, $key, $reset); + } + /** * Explains the current Model query. * diff --git a/tests/system/Database/Builder/ValuePluckTest.php b/tests/system/Database/Builder/ValuePluckTest.php new file mode 100644 index 000000000000..f098453c1a06 --- /dev/null +++ b/tests/system/Database/Builder/ValuePluckTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Builder; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ValuePluckTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $this->db = new MockConnection([]); + } + + public function testValueReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->value('name'); + + $expectedSQL = 'SELECT "name" AS "CI_value" FROM "jobs" WHERE "id" > 3 LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testPluckReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->pluck('name'); + + $expectedSQL = 'SELECT "name" AS "CI_value" FROM "jobs" WHERE "id" > 3'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testPluckWithKeyReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->pluck('name', 'id'); + + $expectedSQL = 'SELECT "name" AS "CI_value", "id" AS "CI_key" FROM "jobs" WHERE "id" > 3'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testValueIgnoresExistingSelectAndRestoresItWhenResetFalse(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->select('description') + ->where('id >', 3) + ->value('name', false); + + $expectedSQL = 'SELECT "name" AS "CI_value" FROM "jobs" WHERE "id" > 3 LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT "description" FROM "jobs" WHERE "id" > 3', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testPluckRestoresExistingSelectWhenResetFalse(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->select('description') + ->where('id >', 3) + ->pluck('name', null, false); + + $expectedSQL = 'SELECT "name" AS "CI_value" FROM "jobs" WHERE "id" > 3'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT "description" FROM "jobs" WHERE "id" > 3', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testValueAndPluckReturnEmptyResultsWhenQueryFails(): void + { + $db = new MockConnection([]); + $db->shouldReturn('execute', false); + + $this->assertNull((new BaseBuilder('jobs', $db))->value('name')); + $this->assertSame([], (new BaseBuilder('jobs', $db))->pluck('name')); + } + + public function testValueRejectsEmptyColumnName(): void + { + $this->expectException(DataException::class); + + (new BaseBuilder('jobs', $this->db))->value(''); + } + + public function testValueRejectsRawSqlExpression(): void + { + $this->expectException(DataException::class); + + (new BaseBuilder('jobs', $this->db))->value('COUNT(*)'); + } + + public function testValueRejectsWildcardColumnName(): void + { + $this->expectException(DataException::class); + + (new BaseBuilder('jobs', $this->db))->value('jobs.*'); + } + + public function testPluckRejectsRawSqlKeyExpression(): void + { + $this->expectException(DataException::class); + + (new BaseBuilder('jobs', $this->db))->pluck('name', 'CONCAT(id, name)'); + } +} diff --git a/tests/system/Database/Live/ValuePluckTest.php b/tests/system/Database/Live/ValuePluckTest.php new file mode 100644 index 000000000000..9d1d7d94d7bc --- /dev/null +++ b/tests/system/Database/Live/ValuePluckTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ValuePluckTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + public function testValueReturnsFirstColumnValue(): void + { + $name = $this->db->table('job') + ->where('id', 1) + ->value('name'); + + $this->assertSame('Developer', $name); + } + + public function testValueReturnsNullWhenNoRowMatches(): void + { + $name = $this->db->table('job') + ->where('name', 'Superstar') + ->value('name'); + + $this->assertNull($name); + } + + public function testValueHonorsOrderingLimitAndOffset(): void + { + $name = $this->db->table('job') + ->orderBy('id', 'ASC') + ->limit(2, 1) + ->value('name'); + + $this->assertSame('Politician', $name); + } + + public function testPluckReturnsColumnValues(): void + { + $names = $this->db->table('job') + ->orderBy('id', 'ASC') + ->pluck('name'); + + $this->assertSame(['Developer', 'Politician', 'Accountant', 'Musician'], $names); + } + + public function testPluckReturnsKeyedColumnValues(): void + { + $names = $this->db->table('job') + ->orderBy('id', 'ASC') + ->pluck('name', 'id'); + + $this->assertSame([ + 1 => 'Developer', + 2 => 'Politician', + 3 => 'Accountant', + 4 => 'Musician', + ], $names); + } +} diff --git a/tests/system/Models/ValuePluckModelTest.php b/tests/system/Models/ValuePluckModelTest.php new file mode 100644 index 000000000000..59358c0cb91c --- /dev/null +++ b/tests/system/Models/ValuePluckModelTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ValuePluckModelTest extends LiveModelTestCase +{ + public function testValueRespectsSoftDeletes(): void + { + $this->createModel(UserModel::class); + $this->model->delete(1); + + $this->assertNull($this->model->where('id', 1)->value('name')); + $this->assertSame('Derek Jones', $this->model->withDeleted()->where('id', 1)->value('name')); + } + + public function testPluckRespectsSoftDeletes(): void + { + $this->createModel(UserModel::class); + $this->model->delete(1); + + $this->assertSame( + ['Ahmadinejad', 'Richard A Causey', 'Chris Martin'], + $this->model->orderBy('id', 'ASC')->pluck('name'), + ); + $this->assertSame( + ['Derek Jones', 'Ahmadinejad', 'Richard A Causey', 'Chris Martin'], + $this->model->withDeleted()->orderBy('id', 'ASC')->pluck('name'), + ); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 359448aa83b5..75f33731999e 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -221,11 +221,12 @@ Query Builder - Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder query would return at least one row. See :ref:`query-builder-exists`. - Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`. - Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`. +- Added ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations. +- Added ``lockForUpdate()`` to add pessimistic write locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-lock-for-update`. +- Added ``pluck()`` and ``value()`` to Query Builder to retrieve scalar column values without fetching full rows. See :ref:`query-builder-pluck` and :ref:`query-builder-value`. - Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. - Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`. -- Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations. -- Added ``lockForUpdate()`` to add pessimistic write locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-lock-for-update`. Forge ----- diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 0144c28eeb65..8c70f148e9f0 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -876,6 +876,57 @@ first parameter. .. literalinclude:: query_builder/073.php +.. _query-builder-value: + +$builder->value() +----------------- + +.. versionadded:: 4.8.0 + +Permits you to retrieve a single column value from the first row of the current +Query Builder query: + +.. literalinclude:: query_builder/130.php + +The ``value()`` method returns the column value, or ``null`` when no row matches. + +This method respects existing Query Builder clauses such as ``where()``, +``join()``, ``orderBy()``, and ``offset()``. It returns the value from the first +matching row and resets the current Query Builder state by default. If you need to +keep the current Query Builder state, you can pass ``false`` as the final +parameter. +It temporarily selects only the requested column, so existing +``select()`` clauses are not used. + +.. warning:: Raw SQL strings are not accepted. If you need to retrieve a custom + SQL expression, use ``select()`` with ``get()`` instead. + Do not pass user-supplied data as column names. + +.. _query-builder-pluck: + +$builder->pluck() +----------------- + +.. versionadded:: 4.8.0 + +Permits you to retrieve a list of values from a single column: + +.. literalinclude:: query_builder/131.php + +The ``pluck()`` method returns a list of values for a single column. If you pass +a second column name to ``pluck()``, that column will be used as the array key. + +This method respects existing Query Builder clauses such as ``where()``, +``join()``, ``orderBy()``, ``limit()``, and ``offset()``, and resets the current +Query Builder state by default. If you need to keep the current Query Builder +state, you can pass ``false`` as the final parameter. +It temporarily selects only the requested column or columns, so existing +``select()`` clauses are not used. + +.. warning:: Raw SQL strings are not accepted. If you need to retrieve a custom + SQL expression, use ``select()`` with ``get()`` instead. + Do not pass user-supplied data as column names. + .. _query-builder-exists: $builder->exists() @@ -1670,6 +1721,25 @@ Class Reference Same as ``get()``, but also allows the WHERE to be added directly. + .. php:method:: value($column[, $reset = true]) + + :param string $column: Column name + :param bool $reset: Whether to reset values for SELECTs + :returns: The column value from the first row, SQL string when test mode is enabled, or ``null`` when no row matches + :rtype: mixed + + Returns a single column value from the first row of the current Query Builder query. + + .. php:method:: pluck($column[, $key = null[, $reset = true]]) + + :param string $column: Column name + :param string|null $key: Optional column name to use as array keys + :param bool $reset: Whether to reset values for SELECTs + :returns: Column values, optionally keyed by another column, or SQL string when test mode is enabled + :rtype: array|string + + Returns the values from a single column of the current Query Builder query. + .. php:method:: lockForUpdate() :returns: ``BaseBuilder`` instance (method chaining) diff --git a/user_guide_src/source/database/query_builder/130.php b/user_guide_src/source/database/query_builder/130.php new file mode 100644 index 000000000000..740cc1ccb0ab --- /dev/null +++ b/user_guide_src/source/database/query_builder/130.php @@ -0,0 +1,3 @@ +where('id', 1)->value('email'); diff --git a/user_guide_src/source/database/query_builder/131.php b/user_guide_src/source/database/query_builder/131.php new file mode 100644 index 000000000000..cb35d5be146d --- /dev/null +++ b/user_guide_src/source/database/query_builder/131.php @@ -0,0 +1,4 @@ +orderBy('name', 'ASC')->pluck('name'); +$namesById = $builder->pluck('name', 'id');