Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int|string, mixed>|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<string> $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.
Expand Down
24 changes: 24 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int|string, mixed>|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.
*
Expand Down
143 changes: 143 additions & 0 deletions tests/system/Database/Builder/ValuePluckTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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)');
}
}
82 changes: 82 additions & 0 deletions tests/system/Database/Live/ValuePluckTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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);
}
}
Loading
Loading