Skip to content

Commit cc6cc7e

Browse files
solracsfbackportbot[bot]
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>
1 parent 5c37f1b commit cc6cc7e

2 files changed

Lines changed: 89 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
@@ -31,6 +31,13 @@ class PreviewMapper extends QBMapper {
3131
private const VERSION_TABLE_NAME = 'preview_versions';
3232
public const MAX_CHUNK_SIZE = 1000;
3333

34+
// Columns selected by joinLocation() that do not belong to the previews table
35+
private const array JOINED_COLUMN_ALIASES = [
36+
'version' => 'v',
37+
'bucket_name' => 'l',
38+
'object_store_name' => 'l',
39+
];
40+
3441
public function __construct(
3542
IDBConnection $db,
3643
private readonly IMimeTypeLoader $mimeTypeLoader,
@@ -134,7 +141,7 @@ public function getAvailablePreviews(array $fileIds): array {
134141
public function getByFileId(int $fileId): \Generator {
135142
$selectQb = $this->db->getQueryBuilder();
136143
$this->joinLocation($selectQb)
137-
->where($selectQb->expr()->eq('file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
144+
->where($selectQb->expr()->eq('p.file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
138145
yield from $this->yieldEntities($selectQb);
139146
}
140147

@@ -247,7 +254,19 @@ public function getPreviewForSpecification(array $parameters): ?Preview {
247254
$this->joinLocation($qb);
248255

249256
foreach ($parameters as $key => $value) {
250-
$qb->andWhere($qb->expr()->eq($key, $qb->createNamedParameter($value)));
257+
// The previews table is joined with preview_versions, which shares
258+
// the file_id column name, so plain column names have to be aliased.
259+
$column = str_contains($key, '.')
260+
? $key
261+
: (self::JOINED_COLUMN_ALIASES[$key] ?? 'p') . '.' . $key;
262+
// An untyped false binds as an empty string, which PostgreSQL
263+
// rejects for a boolean column.
264+
$type = match (true) {
265+
is_bool($value) => IQueryBuilder::PARAM_BOOL,
266+
is_int($value) => IQueryBuilder::PARAM_INT,
267+
default => IQueryBuilder::PARAM_STR,
268+
};
269+
$qb->andWhere($qb->expr()->eq($column, $qb->createNamedParameter($value, $type)));
251270
}
252271

253272
try {

tests/lib/Preview/PreviewMapperTest.php

Lines changed: 68 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,13 +22,15 @@ class PreviewMapperTest extends TestCase {
2122
private PreviewMapper $previewMapper;
2223
private IDBConnection $connection;
2324
private ISnowflakeGenerator $snowflake;
25+
private IMimeTypeLoader $mimeTypeLoader;
2426

2527
#[\Override]
2628
public function setUp(): void {
2729
parent::setUp();
2830
$this->previewMapper = Server::get(PreviewMapper::class);
2931
$this->connection = Server::get(IDBConnection::class);
3032
$this->snowflake = Server::get(ISnowflakeGenerator::class);
33+
$this->mimeTypeLoader = Server::get(IMimeTypeLoader::class);
3134

3235
$qb = $this->connection->getQueryBuilder();
3336
$qb->delete('preview_locations')->executeStatement();
@@ -66,7 +69,7 @@ public function testGetAvailablePreviews(): void {
6669
$this->assertEquals('default', $previews[43][0]->getObjectStoreName());
6770
}
6871

69-
private function createPreviewForFileId(int $fileId, ?int $bucket = null): string {
72+
private function createPreviewForFileId(int $fileId, ?int $bucket = null, int $size = 100, ?string $version = null, bool $cropped = true): string {
7073
$locationId = null;
7174
if ($bucket) {
7275
$qb = $this->connection->getQueryBuilder();
@@ -83,15 +86,16 @@ private function createPreviewForFileId(int $fileId, ?int $bucket = null): strin
8386
$preview->generateId();
8487
$preview->setFileId($fileId);
8588
$preview->setStorageId(1);
86-
$preview->setCropped(true);
89+
$preview->setCropped($cropped);
8790
$preview->setMax(true);
88-
$preview->setWidth(100);
91+
$preview->setWidth($size);
8992
$preview->setSourceMimeType('image/jpeg');
90-
$preview->setHeight(100);
93+
$preview->setHeight($size);
9194
$preview->setSize(100);
9295
$preview->setMtime(time());
9396
$preview->setMimetype('image/jpeg');
9497
$preview->setEtag('abcdefg');
98+
$preview->setVersion($version);
9599

96100
if ($locationId !== null) {
97101
$preview->setLocationId($locationId);
@@ -101,6 +105,66 @@ private function createPreviewForFileId(int $fileId, ?int $bucket = null): strin
101105
return $preview->id;
102106
}
103107

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+
104168
public function testLargeIdInsertRetrieve(): void {
105169
$fileId = PHP_INT_MAX;
106170
$originalPreviewId = $this->createPreviewForFileId($fileId);

0 commit comments

Comments
 (0)