Skip to content
Merged
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
22 changes: 15 additions & 7 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,16 @@ abstract class SQL extends Adapter implements Feature\Connection, Feature\RawQue
private const string JOIN_LABEL_PREFIX = '$join_';

/**
* The `alias.column` label each digest label stands for. A digest names one label for good, so
* the map only grows with the joined columns a process reads.
* The `alias.column` label each digest label stands for, oldest first. Join aliases are the
* caller's, so the map keeps only the newest JOIN_LABELS_KEPT labels instead of every label a
* long-lived worker was ever asked for. A read's labels are the newest when its rows are
* remapped, and an adapter runs one statement at a time, so none of them is evicted first.
*
* @var array<string, string>
*/
private static array $joinLabels = [];
private array $joinLabels = [];

private const int JOIN_LABELS_KEPT = 1024;

/**
* MariaDB and MySQL keep a column label of up to 256 characters.
Expand Down Expand Up @@ -4245,7 +4249,11 @@ private function joinLabel(string $alias, string $column): string
}

$digest = self::JOIN_LABEL_PREFIX.\md5($label);
self::$joinLabels[$digest] = $label;
unset($this->joinLabels[$digest]);
$this->joinLabels[$digest] = $label;
if (\count($this->joinLabels) > self::JOIN_LABELS_KEPT) {
unset($this->joinLabels[\array_key_first($this->joinLabels)]);
}

return $digest;
}
Expand Down Expand Up @@ -6444,10 +6452,10 @@ private function remapRow(array &$row): void
unset($row[$key]);
continue;
}
if (isset(self::$joinLabels[$key])) {
$row[self::$joinLabels[$key]] = $row[$key];
if (isset($this->joinLabels[$key])) {
$row[$this->joinLabels[$key]] = $row[$key];
unset($row[$key]);
$key = self::$joinLabels[$key];
$key = $this->joinLabels[$key];
}
if (\str_starts_with($key, self::FOJ_ORDER_ALIAS_PREFIX)) {
unset($row[$key]);
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/Adapter/JoinLabelRetentionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Tests\Unit\Adapter;

use PDO;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Utopia\Cache\Adapter\None as NoCache;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\SQL;
use Utopia\Database\Adapter\SQLite;
use Utopia\Database\Attribute;
use Utopia\Database\Collection;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Permission;
use Utopia\Database\Query;
use Utopia\Database\Role;
use Utopia\Database\Validator\Authorization;

final class JoinLabelRetentionTest extends TestCase
{
private const string NAMESPACE = 'join_label_retention';

private const int READS = 1100;

private const int KEPT = 1024;

private Database $database;

private SQLite $adapter;

#[\Override]
protected function setUp(): void
{
$this->adapter = new class (new PDO('sqlite::memory:')) extends SQLite {
protected const int MAX_LABEL_LENGTH = 8;
};
$this->database = new Database($this->adapter, new Cache(new NoCache()));
$this->database
->setDatabase(self::NAMESPACE)
->setNamespace(self::NAMESPACE)
->setAuthorization(new Authorization());
$this->database->create();

foreach (['customers' => [Attribute::string('name', size: 64)], 'orders' => [Attribute::string('customerId', size: 64)]] as $id => $attributes) {
$this->database->createCollection(Collection::create(
id: $id,
attributes: $attributes,
permissions: [Permission::create(Role::any()), Permission::read(Role::any())],
documentSecurity: false,
));
}

$this->database->createDocument('customers', new Document(['$id' => 'alice', 'name' => 'Alice']));
$this->database->createDocument('orders', new Document(['$id' => 'o1', 'customerId' => 'alice']));
}

public function testOverlongJoinLabelsAreNotKeptForEveryAliasAWorkerReads(): void
{
for ($read = 0; $read < self::READS; $read++) {
$alias = 'customer_'.$read;
$orders = $this->database->find('orders', [
Query::join('customers', $alias, [Query::on('customerId', $alias.'.$id')]),
Query::select(['$id', $alias.'.name']),
]);

$this->assertCount(1, $orders);
$this->assertSame('Alice', $orders[0]->getAttribute($alias.'.name'), 'The overlong label of read '.$read.' must be read back');
}

$labels = (new ReflectionProperty(SQL::class, 'joinLabels'))->getValue($this->adapter);
$this->assertIsArray($labels);
$this->assertLessThanOrEqual(self::KEPT, \count($labels), 'Every overlong label ever read is still held');
}

public function testAReadKeepsItsLabelsAfterOlderOnesAreEvicted(): void
{
for ($read = 0; $read < self::KEPT; $read++) {
$this->database->find('orders', [
Query::join('customers', 'older_'.$read, [Query::on('customerId', 'older_'.$read.'.$id')]),
Query::select(['$id', 'older_'.$read.'.name']),
]);
}

$orders = $this->database->find('orders', [
Query::join('customers', 'first', [Query::on('customerId', 'first.$id')]),
Query::join('customers', 'second', [Query::on('customerId', 'second.$id')]),
Query::select(['$id', 'first.name', 'second.name']),
]);

$this->assertSame('Alice', $orders[0]->getAttribute('first.name'));
$this->assertSame('Alice', $orders[0]->getAttribute('second.name'));
}
}
Loading