Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ on:
- 'packages/view/src/**'
- 'packages/clock/src/**'
- 'packages/console/src/**'
- 'packages/database/src/**'
- 'packages/event-bus/src/**'
- 'packages/reflection/src/**'
- 'phpbench.json'
- '.github/workflows/benchmark.yml'
Expand Down
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),
);
}
}
50 changes: 36 additions & 14 deletions packages/database/src/GenericDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Tempest\Database\Connection\PDOConnection;
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 Down Expand Up @@ -41,6 +42,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 @@ -49,17 +51,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 @@ -93,16 +91,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 @@ -161,4 +155,32 @@ private function resolveBindings(Query $query): array

return $bindings;
}

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';
}
}
13 changes: 13 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,15 @@ public function test_it_executes_transactions(): void
->withAnyParameters()
->willReturn(true);

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

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

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

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

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

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