Files
Git'Fellow b5222a1b7a 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: https://github.com/nextcloud/server/issues/63229
Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2026-08-18 16:32:14 +02:00

177 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\Files\IMimeTypeLoader;
use OCP\IDBConnection;
use OCP\Server;
use OCP\Snowflake\ISnowflakeGenerator;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class PreviewMapperTest extends TestCase {
private PreviewMapper $previewMapper;
private IDBConnection $connection;
private ISnowflakeGenerator $snowflake;
private IMimeTypeLoader $mimeTypeLoader;
#[\Override]
public function setUp(): void {
parent::setUp();
$this->previewMapper = Server::get(PreviewMapper::class);
$this->connection = Server::get(IDBConnection::class);
$this->snowflake = Server::get(ISnowflakeGenerator::class);
$this->mimeTypeLoader = Server::get(IMimeTypeLoader::class);
$qb = $this->connection->getQueryBuilder();
$qb->delete('preview_locations')->executeStatement();
$qb = $this->connection->getQueryBuilder();
$qb->delete('preview_versions')->executeStatement();
$qb = $this->connection->getQueryBuilder();
$qb->delete('previews')->executeStatement();
}
#[\Override]
public function tearDown(): void {
$this->previewMapper->deleteAll();
parent::tearDown();
}
public function testGetAvailablePreviews(): void {
// Empty
$this->assertEquals([], $this->previewMapper->getAvailablePreviews([]));
// No preview available
$this->assertEquals([42 => []], $this->previewMapper->getAvailablePreviews([42]));
$this->createPreviewForFileId(42);
$previews = $this->previewMapper->getAvailablePreviews([42]);
$this->assertNotEmpty($previews[42]);
$this->assertNull($previews[42][0]->getLocationId());
$this->assertNull($previews[42][0]->getBucketName());
$this->assertNull($previews[42][0]->getObjectStoreName());
$this->createPreviewForFileId(43, 2);
$previews = $this->previewMapper->getAvailablePreviews([43]);
$this->assertNotEmpty($previews[43]);
$this->assertEquals('preview-2', $previews[43][0]->getBucketName());
$this->assertEquals('default', $previews[43][0]->getObjectStoreName());
}
private function createPreviewForFileId(int $fileId, ?int $bucket = null, int $size = 100, ?string $version = null, bool $cropped = true): string {
$locationId = null;
if ($bucket) {
$qb = $this->connection->getQueryBuilder();
$locationId = $this->snowflake->nextId();
$qb->insert('preview_locations')
->values([
'id' => $locationId,
'bucket_name' => $qb->createNamedParameter('preview-' . $bucket),
'object_store_name' => $qb->createNamedParameter('default'),
]);
$qb->executeStatement();
}
$preview = new Preview();
$preview->generateId();
$preview->setFileId($fileId);
$preview->setStorageId(1);
$preview->setCropped($cropped);
$preview->setMax(true);
$preview->setWidth($size);
$preview->setSourceMimeType('image/jpeg');
$preview->setHeight($size);
$preview->setSize(100);
$preview->setMtime(time());
$preview->setMimetype('image/jpeg');
$preview->setEtag('abcdefg');
$preview->setVersion($version);
if ($locationId !== null) {
$preview->setLocationId($locationId);
}
$this->previewMapper->insert($preview);
return $preview->id;
}
/**
* The previews table is joined with preview_versions, which also has a
* file_id column, so the condition has to be qualified with the alias.
*/
public function testGetByFileId(): void {
$fileId = 4242;
$this->createPreviewForFileId($fileId);
$this->createPreviewForFileId($fileId, size: 256);
$this->createPreviewForFileId(4243);
$previews = iterator_to_array($this->previewMapper->getByFileId($fileId));
$this->assertCount(2, $previews);
foreach ($previews as $preview) {
$this->assertSame($fileId, $preview->getFileId());
}
}
/**
* Same ambiguity, reached through the specification lookup that
* Generator::savePreview() uses to recover from a unique constraint
* violation. It passes the cropped flag as a PHP bool, and false is the
* common case, so both values have to be covered.
*/
#[\PHPUnit\Framework\Attributes\TestWith([false])]
#[\PHPUnit\Framework\Attributes\TestWith([true])]
public function testGetPreviewForSpecification(bool $cropped): void {
$fileId = 4244;
$previewId = $this->createPreviewForFileId($fileId, cropped: $cropped);
$preview = $this->previewMapper->getPreviewForSpecification([
'file_id' => $fileId,
'width' => 100,
'height' => 100,
'mimetype_id' => $this->mimeTypeLoader->getId('image/jpeg'),
'cropped' => $cropped,
'version_id' => '-1',
]);
$this->assertNotNull($preview);
$this->assertEquals($previewId, $preview->getId());
}
/**
* version lives in the joined preview_versions table, so it has to keep
* resolving to that alias rather than to the previews table.
*/
public function testGetPreviewForSpecificationOnJoinedColumn(): void {
$fileId = 4245;
$previewId = $this->createPreviewForFileId($fileId, version: '1000');
$preview = $this->previewMapper->getPreviewForSpecification([
'file_id' => $fileId,
'version' => '1000',
]);
$this->assertNotNull($preview);
$this->assertEquals($previewId, $preview->getId());
}
public function testLargeIdInsertRetrieve(): void {
$fileId = PHP_INT_MAX;
$originalPreviewId = $this->createPreviewForFileId($fileId);
$dbPreview = $this->previewMapper->getAvailablePreviews([$fileId])[$fileId][0];
$this->assertEquals($originalPreviewId, $dbPreview->id);
$this->assertEquals($fileId, $dbPreview->getFileId());
}
}