Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* Was die Startseite an Abfragen kostet.
*
* Das Dashboard trägt die meisten Kacheln der Anwendung, und jede holt ihre
* eigenen Zahlen. Zwei Muster treiben die Zahl der Abfragen ohne Gegenwert:
* mehrere Zählungen über dieselbe Tabelle, die sich nur in der Bedingung
* unterscheiden, und Abfragen für Kacheln, die die betrachtende Person
* ausgeblendet hat.
*
* Geprüft wird beides – die zusammengefassten Zählungen gegen ihr Ergebnis und
* die Reihenfolge, in der die Sichtbarkeit feststeht.
*/
use OpenNIT\Content\LoanManager;
use OpenNIT\Content\OrderManager;
use OpenNIT\Core\Database;
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
/**
* Ein Manager mit gezählter Datenbank, die feste Zeilen liefert.
*
* @param array<string,mixed> $zeile
* @return array{0:object, 1:object}
*/
$aufbau = static function (string $klasseName, array $zeile): array {
$db = new class ($zeile) extends Database {
public int $abfragen = 0;
/** @var list<string> */
public array $sql = [];
public function __construct(private array $zeile)
{
}
public function fetchOne(string $sql, array $params = []): ?array
{
$this->abfragen++;
$this->sql[] = $sql;
return $this->zeile;
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
$this->abfragen++;
$this->sql[] = $sql;
return 0;
}
public function fetchAll(string $sql, array $params = []): array
{
$this->abfragen++;
$this->sql[] = $sql;
return [];
}
};
$klasse = new ReflectionClass($klasseName);
$manager = $klasse->newInstanceWithoutConstructor();
$feld = $klasse->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($manager, $db);
return [$manager, $db];
};
// ----------------------------------------------------------------
// Zählungen: zusammenfassen, wo es die Datenbank entlastet
//
// Mehrere Zählungen derselben Tabelle lassen sich bündeln – aber nur, wenn
// die gebündelte Abfrage nicht ihrerseits mehr lesen muss. Verliert sie die
// einschränkende Bedingung und damit den Index, ist der gesparte Weg zur
// Datenbank teurer erkauft als er einbringt.
// ----------------------------------------------------------------
test('Die Bestellzahlen kommen aus einem Durchgang', function () use ($aufbau) {
[$om, $db] = $aufbau(OrderManager::class, [
'offen' => 3, 'geliefert' => 2, 'abholbereit' => 1, 'geschlossen' => 10,
]);
$zahlen = $om->getCounts();
assertSame(1, $db->abfragen, 'Die vier Zustände wurden einzeln gezählt');
assertSame(3, $zahlen['open']);
assertSame(2, $zahlen['delivered']);
assertSame(1, $zahlen['abholbereit']);
assertSame(10, $zahlen['closed']);
assertSame(16, $zahlen['all'], 'Die Summe passt nicht zu den Teilen');
});
test('Fehlende Bestellzahlen ergeben null, keinen Fehler', function () use ($aufbau) {
// Eine leere Tabelle liefert SUM(...) = NULL; daraus muss 0 werden.
[$om] = $aufbau(OrderManager::class, [
'offen' => null, 'geliefert' => null, 'abholbereit' => null, 'geschlossen' => null,
]);
$zahlen = $om->getCounts();
assertSame(0, $zahlen['open']);
assertSame(0, $zahlen['all']);
});
test('Die Fristen der Leihgeräte behalten ihre eigene Bedingung', function () use ($aufbau) {
// Zusammengefasst wären es zwei Abfragen weniger – aber ohne WHERE
// müsste die ganze Tabelle gelesen werden, und die wächst mit jeder je
// getätigten Ausleihe. Der Index über `status` ist hier mehr wert als
// der gesparte Weg zur Datenbank.
[$lm, $db] = $aufbau(LoanManager::class, [
'total' => 5, 'available' => 2, 'lent' => 2, 'reserved' => 1,
'maintenance' => 0, 'retired' => 0,
]);
$lm->getSummary();
foreach (array_slice($db->sql, 1) as $sql) {
assertTrue(
str_contains($sql, 'WHERE status ='),
'Eine Fristen-Abfrage läuft ohne einschränkende Bedingung: ' . $sql
);
}
});
test('Die Zählungen unterscheiden die Fälle, statt alles zu zählen', function () use ($aufbau) {
// Eine zusammengefasste Zählung ist nur dann richtig, wenn jede Zeile
// ihrer Bedingung zugeordnet bleibt.
[$om, $db] = $aufbau(OrderManager::class, ['offen' => 1]);
$om->getCounts();
$sql = $db->sql[0];
foreach (['open', 'delivered', 'abholbereit', 'closed'] as $zustand) {
assertTrue(str_contains($sql, "'" . $zustand . "'"), 'Der Zustand ' . $zustand . ' fehlt in der Abfrage');
}
assertSame(1, substr_count($sql, 'FROM orders'), 'Die Abfrage liest die Tabelle mehr als einmal');
});
// ----------------------------------------------------------------
// Nichts holen, was nicht gezeigt wird
// ----------------------------------------------------------------
test('Die Sichtbarkeit steht vor der ersten Abfrage fest', function () use ($quelle) {
// Wird sie erst danach ermittelt, kann sie keine Abfrage mehr sparen.
$ctrl = $quelle('src/Controllers/DashboardController.php');
$start = strpos($ctrl, 'public function index(');
assertTrue($start !== false, 'Die Startseite fehlt');
$rumpf = substr($ctrl, $start);
$sichtbarkeit = strpos($rumpf, '$hiddenSet = [];');
assertTrue($sichtbarkeit !== false, 'Die ausgeblendeten Kacheln werden nicht ermittelt');
foreach (['new ServiceManager(', 'new OrderManager(', 'new LoanManager('] as $teuer) {
$wo = strpos($rumpf, $teuer);
assertTrue($wo !== false, 'Erwarteter Zugriff fehlt: ' . $teuer);
assertTrue($sichtbarkeit < $wo, $teuer . ' läuft, bevor die Sichtbarkeit feststeht');
}
});
test('Jede aufwendige Kachel fragt nur, wenn sie gezeigt wird', function () use ($quelle) {
$ctrl = $quelle('src/Controllers/DashboardController.php');
// Je Kachel die Bedingung, unter der ihre Daten geholt werden.
$kacheln = [
'loan_devices' => "\$hasViewLoans && \$zeigt('loan_devices')",
'my_todos' => "\$hasViewTodos && \$zeigt('my_todos')",
'my_kanban_tasks' => "\$hasViewKanban && \$zeigt('my_kanban_tasks')",
'unifi_vouchers' => "\$zeigt('unifi_vouchers')",
'monitoring' => "!in_array('monitoring', \$hiddenSet, true)",
'backup' => "!in_array('backup', \$hiddenSet, true)",
'cve_security' => "!in_array('cve_security', \$hiddenSet, true)",
'contracts_expiring' => "!in_array('contracts_expiring', \$hiddenSet, true)",
'workflow_approvals' => "!in_array('workflow_approvals', \$hiddenSet, true)",
];
foreach ($kacheln as $kachel => $bedingung) {
assertTrue(
str_contains($ctrl, $bedingung),
'Die Kachel ' . $kachel . ' holt ihre Daten unabhängig davon, ob sie gezeigt wird'
);
}
});
test('Die Einstellung der Kacheln wird einmal gelesen', function () use ($quelle) {
// Zweimal gelesen wäre eine Abfrage für nichts – und die zweite könnte
// eine andere Antwort geben als die erste.
$ctrl = $quelle('src/Controllers/DashboardController.php');
assertSame(
1,
substr_count($ctrl, "\$this->getUserSetting('dashboard_widgets')"),
'Die Kachel-Einstellung wird mehrfach gelesen'
);
});
test('Die Seitenzahlen zählen Seiten, nicht Verknüpfungszeilen', function () use ($quelle) {
// Die Zuständigkeiten werden angehängt; ohne DISTINCT zählte eine Seite
// mit mehreren Einträgen mehrfach.
$ctrl = $quelle('src/Controllers/DashboardController.php');
$von = strpos($ctrl, 'FROM pages p');
assertTrue($von !== false, 'Die Seitenzählung fehlt');
$abfrage = substr($ctrl, max(0, $von - 500), 800);
assertTrue(str_contains($abfrage, 'COUNT(DISTINCT p.id)'), 'Die Gesamtzahl zählt Zeilen statt Seiten');
assertTrue(str_contains($abfrage, 'LEFT JOIN page_responsibilities'), 'Ohne LEFT JOIN fehlten Seiten ohne Zuständigkeit');
});
};