Skip to content

Commit b5222a1

Browse files
solracsfCarlSchwan
authored andcommitted
fix(preview): qualify the ambiguous columns in PreviewMapper queries
joinLocation() joins previews (p) with preview_locations (l) and preview_versions (v). Both previews and preview_versions have a file_id column, so any unqualified file_id condition is ambiguous and MySQL or MariaDB reject the query with error 1052. getPreviewForSpecification() built its conditions straight from the caller's array keys, so this broke every preview save: savePreview() uses that lookup to recover the existing row after a unique constraint violation. getByFileId() had the same unqualified condition, while getAvailablePreviewsForFile() next to it already used p.file_id. Columns that come from the joined tables keep resolving to their own alias, and keys that already carry one are passed through untouched. The values are bound with an explicit type as well. An untyped false binds as an empty string, which PostgreSQL rejects for a boolean column, so qualifying the columns on their own only moved the error on that backend. Fixes: #63229 Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent 8a3413a commit b5222a1

2 files changed

Lines changed: 102 additions & 6 deletions

File tree

lib/private/Preview/Db/PreviewMapper.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ class PreviewMapper extends QBMapper {
2828
private const LOCATION_TABLE_NAME = 'preview_locations';
2929
private const VERSION_TABLE_NAME = 'preview_versions';
3030

31+
// Columns selected by joinLocation() that do not belong to the previews table
32+
private const JOINED_COLUMN_ALIASES = [
33+
'version' => 'v',
34+
'bucket_name' => 'l',
35+
'object_store_name' => 'l',
36+
];
37+
3138
public function __construct(
3239
IDBConnection $db,
3340
private readonly IMimeTypeLoader $mimeTypeLoader,
@@ -126,7 +133,7 @@ public function getAvailablePreviews(array $fileIds): array {
126133
public function getByFileId(int $fileId): \Generator {
127134
$selectQb = $this->db->getQueryBuilder();
128135
$this->joinLocation($selectQb)
129-
->where($selectQb->expr()->eq('file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
136+
->where($selectQb->expr()->eq('p.file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
130137
yield from $this->yieldEntities($selectQb);
131138
}
132139

@@ -234,7 +241,19 @@ public function getPreviewForSpecification(array $parameters): ?Preview {
234241
$this->joinLocation($qb);
235242

236243
foreach ($parameters as $key => $value) {
237-
$qb->andWhere($qb->expr()->eq($key, $qb->createNamedParameter($value)));
244+
// The previews table is joined with preview_versions, which shares
245+
// the file_id column name, so plain column names have to be aliased.
246+
$column = str_contains($key, '.')
247+
? $key
248+
: (self::JOINED_COLUMN_ALIASES[$key] ?? 'p') . '.' . $key;
249+
// An untyped false binds as an empty string, which PostgreSQL
250+
// rejects for a boolean column.
251+
$type = match (true) {
252+
is_bool($value) => IQueryBuilder::PARAM_BOOL,
253+
is_int($value) => IQueryBuilder::PARAM_INT,
254+
default => IQueryBuilder::PARAM_STR,
255+
};
256+
$qb->andWhere($qb->expr()->eq($column, $qb->createNamedParameter($value, $type)));
238257
}
239258

240259
try {

tests/lib/Preview/PreviewMapperTest.php

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use OC\Preview\Db\Preview;
1313
use OC\Preview\Db\PreviewMapper;
14+
use OCP\Files\IMimeTypeLoader;
1415
use OCP\IDBConnection;
1516
use OCP\Server;
1617
use OCP\Snowflake\ISnowflakeGenerator;
@@ -21,12 +22,15 @@ class PreviewMapperTest extends TestCase {
2122
private PreviewMapper $previewMapper;
2223
private IDBConnection $connection;
2324
private ISnowflakeGenerator $snowflake;
25+
private IMimeTypeLoader $mimeTypeLoader;
2426

27+
#[\Override]
2528
public function setUp(): void {
2629
parent::setUp();
2730
$this->previewMapper = Server::get(PreviewMapper::class);
2831
$this->connection = Server::get(IDBConnection::class);
2932
$this->snowflake = Server::get(ISnowflakeGenerator::class);
33+
$this->mimeTypeLoader = Server::get(IMimeTypeLoader::class);
3034

3135
$qb = $this->connection->getQueryBuilder();
3236
$qb->delete('preview_locations')->executeStatement();
@@ -38,6 +42,7 @@ public function setUp(): void {
3842
$qb->delete('previews')->executeStatement();
3943
}
4044

45+
#[\Override]
4146
public function tearDown(): void {
4247
$this->previewMapper->deleteAll();
4348
parent::tearDown();
@@ -64,7 +69,7 @@ public function testGetAvailablePreviews(): void {
6469
$this->assertEquals('default', $previews[43][0]->getObjectStoreName());
6570
}
6671

67-
private function createPreviewForFileId(int $fileId, ?int $bucket = null): void {
72+
private function createPreviewForFileId(int $fileId, ?int $bucket = null, int $size = 100, ?string $version = null, bool $cropped = true): string {
6873
$locationId = null;
6974
if ($bucket) {
7075
$qb = $this->connection->getQueryBuilder();
@@ -81,19 +86,91 @@ private function createPreviewForFileId(int $fileId, ?int $bucket = null): void
8186
$preview->generateId();
8287
$preview->setFileId($fileId);
8388
$preview->setStorageId(1);
84-
$preview->setCropped(true);
89+
$preview->setCropped($cropped);
8590
$preview->setMax(true);
86-
$preview->setWidth(100);
91+
$preview->setWidth($size);
8792
$preview->setSourceMimeType('image/jpeg');
88-
$preview->setHeight(100);
93+
$preview->setHeight($size);
8994
$preview->setSize(100);
9095
$preview->setMtime(time());
9196
$preview->setMimetype('image/jpeg');
9297
$preview->setEtag('abcdefg');
98+
$preview->setVersion($version);
9399

94100
if ($locationId !== null) {
95101
$preview->setLocationId($locationId);
96102
}
97103
$this->previewMapper->insert($preview);
104+
105+
return $preview->id;
106+
}
107+
108+
/**
109+
* The previews table is joined with preview_versions, which also has a
110+
* file_id column, so the condition has to be qualified with the alias.
111+
*/
112+
public function testGetByFileId(): void {
113+
$fileId = 4242;
114+
$this->createPreviewForFileId($fileId);
115+
$this->createPreviewForFileId($fileId, size: 256);
116+
$this->createPreviewForFileId(4243);
117+
118+
$previews = iterator_to_array($this->previewMapper->getByFileId($fileId));
119+
120+
$this->assertCount(2, $previews);
121+
foreach ($previews as $preview) {
122+
$this->assertSame($fileId, $preview->getFileId());
123+
}
124+
}
125+
126+
/**
127+
* Same ambiguity, reached through the specification lookup that
128+
* Generator::savePreview() uses to recover from a unique constraint
129+
* violation. It passes the cropped flag as a PHP bool, and false is the
130+
* common case, so both values have to be covered.
131+
*/
132+
#[\PHPUnit\Framework\Attributes\TestWith([false])]
133+
#[\PHPUnit\Framework\Attributes\TestWith([true])]
134+
public function testGetPreviewForSpecification(bool $cropped): void {
135+
$fileId = 4244;
136+
$previewId = $this->createPreviewForFileId($fileId, cropped: $cropped);
137+
138+
$preview = $this->previewMapper->getPreviewForSpecification([
139+
'file_id' => $fileId,
140+
'width' => 100,
141+
'height' => 100,
142+
'mimetype_id' => $this->mimeTypeLoader->getId('image/jpeg'),
143+
'cropped' => $cropped,
144+
'version_id' => '-1',
145+
]);
146+
147+
$this->assertNotNull($preview);
148+
$this->assertEquals($previewId, $preview->getId());
149+
}
150+
151+
/**
152+
* version lives in the joined preview_versions table, so it has to keep
153+
* resolving to that alias rather than to the previews table.
154+
*/
155+
public function testGetPreviewForSpecificationOnJoinedColumn(): void {
156+
$fileId = 4245;
157+
$previewId = $this->createPreviewForFileId($fileId, version: '1000');
158+
159+
$preview = $this->previewMapper->getPreviewForSpecification([
160+
'file_id' => $fileId,
161+
'version' => '1000',
162+
]);
163+
164+
$this->assertNotNull($preview);
165+
$this->assertEquals($previewId, $preview->getId());
166+
}
167+
168+
public function testLargeIdInsertRetrieve(): void {
169+
$fileId = PHP_INT_MAX;
170+
$originalPreviewId = $this->createPreviewForFileId($fileId);
171+
172+
$dbPreview = $this->previewMapper->getAvailablePreviews([$fileId])[$fileId][0];
173+
$this->assertEquals($originalPreviewId, $dbPreview->id);
174+
$this->assertEquals($fileId, $dbPreview->getFileId());
98175
}
99176
}

0 commit comments

Comments
 (0)