Skip to content
Draft
2 changes: 2 additions & 0 deletions packages/database/src/DatabaseInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Tempest\Database\Connection\Connection;
use Tempest\Database\Connection\PDOConnection;
use Tempest\Database\Transactions\GenericTransactionManager;
use Tempest\EventBus\EventBus;
use Tempest\Mapper\SerializerFactory;
use Tempest\Reflection\ClassReflector;
use UnitEnum;
Expand Down Expand Up @@ -44,6 +45,7 @@ className: Connection::class,
connection: $connection,
transactionManager: new GenericTransactionManager($connection),
serializerFactory: $container->get(SerializerFactory::class),
eventBus: $container->get(EventBus::class),
);
}
}
51 changes: 37 additions & 14 deletions packages/database/src/GenericDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Tempest\Database\Connection\Connection;
use Tempest\Database\Exceptions\QueryWasInvalid;
use Tempest\Database\Transactions\TransactionManager;
use Tempest\EventBus\EventBus;
use Tempest\Mapper\SerializerFactory;
use Tempest\Support\Str\ImmutableString;
use Throwable;
Expand All @@ -38,6 +39,7 @@ public function __construct(
private(set) readonly Connection $connection,
private(set) readonly TransactionManager $transactionManager,
private(set) readonly SerializerFactory $serializerFactory,
private readonly EventBus $eventBus,
) {}

public function execute(BuildsQuery|Query $query): void
Expand All @@ -46,17 +48,13 @@ public function execute(BuildsQuery|Query $query): void
$query = $query->build();
}

$bindings = $this->resolveBindings($query);

try {
$statement = $this->connection->prepare($query->compile()->toString());
$this->runQuery($query, function (string $sql, array $bindings) use ($query): void {
$statement = $this->connection->prepare($sql);
$statement->execute($bindings);

$this->lastStatement = $statement;
$this->lastQuery = $query;
} catch (PDOException $pdoException) {
throw new QueryWasInvalid($query, $bindings, $pdoException);
}
});
}

public function getLastInsertId(): ?PrimaryKey
Expand Down Expand Up @@ -90,16 +88,12 @@ public function fetch(BuildsQuery|Query $query): array
$query = $query->build();
}

$bindings = $this->resolveBindings($query);

try {
$pdoQuery = $this->connection->prepare($query->compile()->toString());
return $this->runQuery($query, function (string $sql, array $bindings): array {
$pdoQuery = $this->connection->prepare($sql);
$pdoQuery->execute($bindings);

return $pdoQuery->fetchAll(PDO::FETCH_NAMED);
} catch (PDOException $pdoException) {
throw new QueryWasInvalid($query, $bindings, $pdoException);
}
});
}

public function fetchFirst(BuildsQuery|Query $query): ?array
Expand Down Expand Up @@ -158,4 +152,33 @@ private function resolveBindings(Query $query): array

return $bindings;
}

/** @template TResult */
private function runQuery(Query $query, callable $runner): mixed
{
$bindings = $this->resolveBindings($query);
$sql = $query->compile()->toString();
$failed = true;
$startTime = hrtime(true);

try {
$result = $runner($sql, $bindings);
$failed = false;

return $result;
} catch (PDOException $pdoException) {
throw new QueryWasInvalid($query, $bindings, $pdoException);
} finally {
try {
$this->eventBus->dispatch(new QueryExecuted(
sql: $sql,
bindings: $bindings,
durationMs: (hrtime(true) - $startTime) / 1_000_000,
connectionName: $this->tag,
failed: $failed,
));
} catch (Throwable) { // @mago-ignore lint:no-empty-catch-clause
}
}
}
}
123 changes: 123 additions & 0 deletions packages/database/src/QueryAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

use Tempest\Database\Config\DatabaseDialect;
use Throwable;

use function Tempest\Support\Arr\contains;

final class QueryAnalyzer
{
private ?array $explainResult = null;
private bool $explainComputed = false;

public function __construct(
private(set) readonly QueryExecuted $query,
private readonly Database $database,
) {}

public function explain(): ?array
{
if ($this->explainComputed) {
return $this->explainResult;
}

$this->explainComputed = true;

if (! $this->query->isSelect()) {
return null;
}

try {
$this->explainResult = $this->database->fetch(
new Query($this->getExplainSql(), $this->query->bindings),
);
} catch (Throwable) {
$this->explainResult = null;
}

return $this->explainResult;
}

public function usesFullTableScan(): bool
{
$explain = $this->explain();

if ($explain === null) {
return false;
}

return contains($explain, static function (array $row): bool {
$isFullScanType = isset($row['type']) && strtoupper($row['type']) === 'ALL';
$hasScanInDetail = isset($row['detail']) && str_contains(strtoupper($row['detail']), 'SCAN');

return $isFullScanType || $hasScanInDetail;
});
}

public function getRowsExamined(): int
{
$explain = $this->explain();

if ($explain === null) {
return 0;
}

$total = 0;

foreach ($explain as $row) {
if (isset($row['rows'])) {
$total += (int) $row['rows'];
}

if (isset($row['detail']) && preg_match('/~(\d+) rows/i', $row['detail'], $matches)) {
$total += (int) $matches[1];
}
}

return $total;
}

public function usesIndex(): bool
{
return $this->getIndexUsed() !== null;
}

public function getIndexUsed(): ?string
{
$explain = $this->explain();

if ($explain === null) {
return null;
}

foreach ($explain as $row) {
if (isset($row['key']) && $row['key'] !== '') {
return $row['key'];
}

if (isset($row['detail'])) {
if (preg_match('/USING INDEX (\S+)/i', $row['detail'], $matches)) {
return $matches[1];
}

if (preg_match('/USING (INTEGER )?PRIMARY KEY/i', $row['detail'])) {
return 'PRIMARY KEY';
}
}
}

return null;
}

private function getExplainSql(): string
{
return match ($this->database->dialect) {
DatabaseDialect::SQLITE => "EXPLAIN QUERY PLAN {$this->query->sql}",
default => "EXPLAIN {$this->query->sql}",
};
}
}
50 changes: 50 additions & 0 deletions packages/database/src/QueryExecuted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

use UnitEnum;

use function Tempest\Support\Str\before_first;
use function Tempest\Support\Str\to_upper_case;

final class QueryExecuted
{
public string $queryType {
get => to_upper_case(before_first(trim($this->sql), ' '));
}

public function __construct(
public string $sql,
public array $bindings,
public float $durationMs,
public null|string|UnitEnum $connectionName,
public bool $failed,
) {}

public function isSlow(float $thresholdMs = 100.0): bool
{
return $this->durationMs > $thresholdMs;
}

public function isSelect(): bool
{
return $this->queryType === 'SELECT';
}

public function isInsert(): bool
{
return $this->queryType === 'INSERT';
}

public function isUpdate(): bool
{
return $this->queryType === 'UPDATE';
}

public function isDelete(): bool
{
return $this->queryType === 'DELETE';
}
}
11 changes: 11 additions & 0 deletions packages/database/tests/GenericDatabaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use Tempest\Database\Connection\Connection;
use Tempest\Database\GenericDatabase;
use Tempest\Database\Transactions\GenericTransactionManager;
use Tempest\EventBus\EventBusConfig;
use Tempest\EventBus\GenericEventBus;
use Tempest\EventBus\Testing\FakeEventBus;
use Tempest\Mapper\SerializerFactory;

/**
Expand All @@ -31,10 +34,14 @@ public function test_it_executes_transactions(): void
->withAnyParameters()
->willReturn(true);

$container = new GenericContainer();
$eventBus = new FakeEventBus(new GenericEventBus($container, new EventBusConfig()));

$database = new GenericDatabase(
$connection,
new GenericTransactionManager($connection),
new SerializerFactory(new GenericContainer()),
$eventBus,
);

$result = $database->withinTransaction(function () {
Expand All @@ -58,10 +65,14 @@ public function test_it_rolls_back_transactions_on_failure(): void
->withAnyParameters()
->willReturn(true);

$container = new GenericContainer();
$eventBus = new FakeEventBus(new GenericEventBus($container, new EventBusConfig()));

$database = new GenericDatabase(
$connection,
new GenericTransactionManager($connection),
new SerializerFactory(new GenericContainer()),
$eventBus,
);

$result = $database->withinTransaction(function (): never {
Expand Down
Loading