Skip to content

Commit d47b6b1

Browse files
authored
Handle cached result column names and rows separately (#6504)
| Q | A |------------- | ----------- | Type | bug #### Summary Prior to #6428, there were two problems with caching results by design: 1. If the result contains no rows, its cached version won't return the number of columns. 2. If the result contains columns with the same name (e.g. `SELECT a.id, b.id FROM a, b`), they will clash even if fetched in the numeric mode. See #6428 (comment) for reference. The solution is: 1. Handle column names and rows in the cache result separately. This way, the column names are available even if there is no data. 2. Store rows as tuples (lists) instead of maps (associative arrays). This way, even if the result contains multiple columns with the same name, they can be correctly fetched with `fetchNumeric()`. **Additional notes**: 1. The changes in `TestUtil::generateResultSetQuery()` were necessary for the integration testing of the changes in `ArrayResult`. However, later, I realized that the modified code is database-agnostic, so running it in integration with each database platform would effectively test the same code path and would be redundant. I replaced the integration tests with the unit ones but left the change in `TestUtil` as it makes the calling code cleaner. 2. `ArrayStatementTest` was renamed to `ArrayResultTest`, which Git doesn't seem to recognize due to the amount of changes. **TODO**: - [x] Decide on the release version. This pull request changes the cache format, so the users will have to clear their cache during upgrade. I wouldn't consider it a BC break. We could also migrate the cache at runtime, but given that this cache can be cleared, I don't think it is worth the effort. - [x] Add upgrade documentation.
1 parent b9183ca commit d47b6b1

File tree

9 files changed

+176
-163
lines changed

9 files changed

+176
-163
lines changed

UPGRADE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ awareness about deprecated code.
66
- Use of our low-overhead runtime deprecation API, details:
77
https://github.com/doctrine/deprecations/
88

9+
# Upgrade to 4.2
10+
11+
## Minor BC break: incompatible query cache format
12+
13+
The query cache format has been changed to address the issue where a cached result with no rows would miss the metadata.
14+
This change is not backwards compatible. If you are using the query cache, you should clear the cache before the
15+
upgrade.
16+
917
# Upgrade to 4.1
1018

1119
## Deprecated `TableDiff` methods

src/Cache/ArrayResult.php

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,37 @@
88
use Doctrine\DBAL\Driver\Result;
99
use Doctrine\DBAL\Exception\InvalidColumnIndex;
1010

11-
use function array_keys;
12-
use function array_values;
11+
use function array_combine;
1312
use function count;
14-
use function reset;
1513

1614
/** @internal The class is internal to the caching layer implementation. */
1715
final class ArrayResult implements Result
1816
{
19-
private readonly int $columnCount;
2017
private int $num = 0;
2118

22-
/** @param list<array<string, mixed>> $data */
23-
public function __construct(private array $data)
19+
/**
20+
* @param list<string> $columnNames The names of the result columns. Must be non-empty.
21+
* @param list<list<mixed>> $rows The rows of the result. Each row must have the same number of columns
22+
* as the number of column names.
23+
*/
24+
public function __construct(private readonly array $columnNames, private array $rows)
2425
{
25-
$this->columnCount = $data === [] ? 0 : count($data[0]);
2626
}
2727

2828
public function fetchNumeric(): array|false
29+
{
30+
return $this->fetch();
31+
}
32+
33+
public function fetchAssociative(): array|false
2934
{
3035
$row = $this->fetch();
3136

3237
if ($row === false) {
3338
return false;
3439
}
3540

36-
return array_values($row);
37-
}
38-
39-
public function fetchAssociative(): array|false
40-
{
41-
return $this->fetch();
41+
return array_combine($this->columnNames, $row);
4242
}
4343

4444
public function fetchOne(): mixed
@@ -49,7 +49,7 @@ public function fetchOne(): mixed
4949
return false;
5050
}
5151

52-
return reset($row);
52+
return $row[0];
5353
}
5454

5555
/**
@@ -78,32 +78,31 @@ public function fetchFirstColumn(): array
7878

7979
public function rowCount(): int
8080
{
81-
return count($this->data);
81+
return count($this->rows);
8282
}
8383

8484
public function columnCount(): int
8585
{
86-
return $this->columnCount;
86+
return count($this->columnNames);
8787
}
8888

8989
public function getColumnName(int $index): string
9090
{
91-
return array_keys($this->data[0] ?? [])[$index]
92-
?? throw InvalidColumnIndex::new($index);
91+
return $this->columnNames[$index] ?? throw InvalidColumnIndex::new($index);
9392
}
9493

9594
public function free(): void
9695
{
97-
$this->data = [];
96+
$this->rows = [];
9897
}
9998

100-
/** @return array<string, mixed>|false */
99+
/** @return list<mixed>|false */
101100
private function fetch(): array|false
102101
{
103-
if (! isset($this->data[$this->num])) {
102+
if (! isset($this->rows[$this->num])) {
104103
return false;
105104
}
106105

107-
return $this->data[$this->num++];
106+
return $this->rows[$this->num++];
108107
}
109108
}

src/Connection.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -811,15 +811,24 @@ public function executeCacheQuery(string $sql, array $params, array $types, Quer
811811
}
812812

813813
if (isset($value[$realKey])) {
814-
return new Result(new ArrayResult($value[$realKey]), $this);
814+
[$columnNames, $rows] = $value[$realKey];
815+
816+
return new Result(new ArrayResult($columnNames, $rows), $this);
815817
}
816818
} else {
817819
$value = [];
818820
}
819821

820-
$data = $this->fetchAllAssociative($sql, $params, $types);
822+
$result = $this->executeQuery($sql, $params, $types);
823+
824+
$columnNames = [];
825+
for ($i = 0; $i < $result->columnCount(); $i++) {
826+
$columnNames[] = $result->getColumnName($i);
827+
}
828+
829+
$rows = $result->fetchAllNumeric();
821830

822-
$value[$realKey] = $data;
831+
$value[$realKey] = [$columnNames, $rows];
823832

824833
$item->set($value);
825834

@@ -830,7 +839,7 @@ public function executeCacheQuery(string $sql, array $params, array $types, Quer
830839

831840
$resultCache->save($item);
832841

833-
return new Result(new ArrayResult($data), $this);
842+
return new Result(new ArrayResult($columnNames, $rows), $this);
834843
}
835844

836845
/**

tests/Cache/ArrayResultTest.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\DBAL\Tests\Cache;
6+
7+
use Doctrine\DBAL\Cache\ArrayResult;
8+
use Doctrine\DBAL\Exception\InvalidColumnIndex;
9+
use PHPUnit\Framework\Attributes\TestWith;
10+
use PHPUnit\Framework\TestCase;
11+
12+
class ArrayResultTest extends TestCase
13+
{
14+
private ArrayResult $result;
15+
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
20+
$this->result = new ArrayResult(['username', 'active'], [
21+
['jwage', true],
22+
['romanb', false],
23+
]);
24+
}
25+
26+
public function testFree(): void
27+
{
28+
self::assertSame(2, $this->result->rowCount());
29+
30+
$this->result->free();
31+
32+
self::assertSame(0, $this->result->rowCount());
33+
}
34+
35+
public function testColumnCount(): void
36+
{
37+
self::assertSame(2, $this->result->columnCount());
38+
}
39+
40+
public function testColumnNames(): void
41+
{
42+
self::assertSame('username', $this->result->getColumnName(0));
43+
self::assertSame('active', $this->result->getColumnName(1));
44+
}
45+
46+
#[TestWith([2])]
47+
#[TestWith([-1])]
48+
public function testColumnNameWithInvalidIndex(int $index): void
49+
{
50+
$this->expectException(InvalidColumnIndex::class);
51+
52+
$this->result->getColumnName($index);
53+
}
54+
55+
public function testRowCount(): void
56+
{
57+
self::assertSame(2, $this->result->rowCount());
58+
}
59+
60+
public function testFetchAssociative(): void
61+
{
62+
self::assertSame([
63+
'username' => 'jwage',
64+
'active' => true,
65+
], $this->result->fetchAssociative());
66+
}
67+
68+
public function testFetchNumeric(): void
69+
{
70+
self::assertSame(['jwage', true], $this->result->fetchNumeric());
71+
}
72+
73+
public function testFetchOne(): void
74+
{
75+
self::assertSame('jwage', $this->result->fetchOne());
76+
self::assertSame('romanb', $this->result->fetchOne());
77+
}
78+
79+
public function testFetchAllAssociative(): void
80+
{
81+
self::assertSame([
82+
[
83+
'username' => 'jwage',
84+
'active' => true,
85+
],
86+
[
87+
'username' => 'romanb',
88+
'active' => false,
89+
],
90+
], $this->result->fetchAllAssociative());
91+
}
92+
93+
public function testEmptyResult(): void
94+
{
95+
$result = new ArrayResult(['a'], []);
96+
self::assertSame('a', $result->getColumnName(0));
97+
}
98+
99+
public function testSameColumnNames(): void
100+
{
101+
$result = new ArrayResult(['a', 'a'], [[1, 2]]);
102+
103+
self::assertSame('a', $result->getColumnName(0));
104+
self::assertSame('a', $result->getColumnName(1));
105+
106+
self::assertEquals([1, 2], $result->fetchNumeric());
107+
}
108+
}

tests/Cache/ArrayStatementTest.php

Lines changed: 0 additions & 104 deletions
This file was deleted.

0 commit comments

Comments
 (0)