Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3057900
feat explain idx
premtsd-code May 18, 2026
9896324
fix test
premtsd-code May 18, 2026
d08ee14
fix explain capture on Mongo and MariaDB
premtsd-code May 18, 2026
6944cd4
fix explain crash on count/sum, pool transactions, nested scopes
premtsd-code May 19, 2026
5031ab1
Merge remote-tracking branch 'origin/main' into feat/explain
premtsd-code May 23, 2026
939a4d3
Add real execution stats and precise engine labels to explain plans
premtsd-code Jun 1, 2026
4176cef
Fix phpstan errors and document withExplain return contract
premtsd-code Jun 1, 2026
9dbfdca
Trim redundant explain comments to the essential why
premtsd-code Jun 1, 2026
e97cac6
Fix withExplain return value and stop Mongo double-execution
premtsd-code Jun 1, 2026
8707f19
Route SQL and SQLite EXPLAIN through execute() for consistent error h…
premtsd-code Jun 1, 2026
343e9c2
Preserve original Mongo count() body when adding explain timing
premtsd-code Jun 1, 2026
afee5a9
Add getSupportForExplain() capability and test Mongo explain
premtsd-code Jun 1, 2026
ff60421
Fix re-entrant explain capture on pinned pool adapter
premtsd-code Jun 1, 2026
7049eb9
Sanitize embedded perms/metadata table tokens in explain plan strings
premtsd-code Jun 2, 2026
ebedb0a
Clear stale pool adapter timeouts
premtsd-code Jun 2, 2026
177e593
Strip internal schema name from explain plan condition strings
premtsd-code Jun 2, 2026
9770767
(fix): CI — preserve Pool::setTimeout state across delegate() calls
github-actions[bot] Jun 2, 2026
3efcae2
Fix pooled timeout event replay
premtsd-code Jun 2, 2026
21441cc
Keep pooled explain changes scoped
premtsd-code Jun 2, 2026
658bc2c
Avoid pooled explain overhead outside capture
premtsd-code Jun 2, 2026
80356e7
Drop unrelated clearTimeout change and fix pint new_with_parentheses
premtsd-code Jun 3, 2026
3875348
Trim redundant explain comment restating reassigned variable
premtsd-code Jun 3, 2026
9164948
Remove explain engine field
premtsd-code Jun 3, 2026
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
181 changes: 181 additions & 0 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ abstract class Adapter
*/
protected array $debug = [];

/**
* @var ?array<int, array<string, mixed>>
*/
protected ?array $explainBuffer = null;

/**
* @var array<string, string>
*/
protected const EXPLAIN_COLUMN_RENAMES = [
'_uid' => '$id',
'_createdAt' => '$createdAt',
'_updatedAt' => '$updatedAt',
'_permissions' => '$permissions',
'_tenant' => '$tenant',
];

/**
* @var array<string, array<callable>>
*/
Expand Down Expand Up @@ -104,6 +120,171 @@ public function resetDebug(): static
return $this;
}

/**
* @throws DatabaseException when called inside an already-active scope —
* the buffer is a single shared array, so silently clobbering the outer
* scope would lose every previously-captured entry.
*/
public function startExplainCapture(): void
{
if ($this->explainBuffer !== null) {
throw new DatabaseException('withExplain cannot be nested — finish the outer scope first.');
}
$this->explainBuffer = [];
}

/**
* @return array<int, array<string, mixed>>
*/
public function stopExplainCapture(): array
{
$captured = $this->explainBuffer ?? [];
$this->explainBuffer = null;
return $captured;
}

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

/**
* @param string $sql
* @param array<string, mixed> $binds
* @param string $purpose
* @param array<string, mixed> $context
*/
protected function capturePlan(string $sql, array $binds = [], string $purpose = 'find', array $context = []): void
{
try {
$plan = $this->explainSQL($sql, $binds);
$plan = $this->sanitizePlan($plan);
} catch (\Throwable $e) {
$plan = ['error' => $e->getMessage()];
}

$this->explainBuffer[] = [
'purpose' => $purpose,
'context' => $context,
'plan' => $plan,
];
}

/**
* Attach the REAL execution stats to the most recently captured plan entry.
*
* The explain endpoint runs the same query listRows runs, so instead of
* paying for a second EXPLAIN ANALYZE pass we just measure the read that
* already happens: callers time their actual statement and report the
* actual rows it produced. No-op when not capturing, so the normal read
* path is untouched.
*
* @param int|null $rowsReturned actual rows the statement returned (null when not meaningful, e.g. an aggregate)
* @param float|null $executionTime actual wall time of the statement in milliseconds
*/
protected function recordPlanActuals(?int $rowsReturned, ?float $executionTime): void
{
if ($this->explainBuffer === null || $this->explainBuffer === []) {
return;
}
$last = \array_key_last($this->explainBuffer);
$plan = $this->explainBuffer[$last]['plan'] ?? null;
// capturePlan() stores the entry just before the real statement runs;
// only fill actuals when the captured plan is a well-formed array. A
// failed EXPLAIN is stored as ['error' => ...] — leave it untouched so
// an error entry never masquerades as a real plan with stats.
if (! \is_array($plan) || isset($plan['error'])) {
return;
}
$this->explainBuffer[$last]['plan']['rowsReturned'] = $rowsReturned;
$this->explainBuffer[$last]['plan']['executionTime'] = $executionTime;
}

/**
* @param array<int|string, mixed> $plan
* @return array<int|string, mixed>
*/
protected function sanitizePlan(array $plan): array
{
return $this->sanitizePlanNode($plan);
}

private function sanitizePlanNode(mixed $node): mixed
{
if (\is_array($node)) {
$result = [];
foreach ($node as $key => $value) {
$newKey = \is_string($key) ? $this->renameInternalIdentifier($key) : $key;
$result[$newKey] = $this->sanitizePlanNode($value);
}
return $result;
}

if (\is_string($node)) {
return $this->renameInternalIdentifier($node);
}

return $node;
}

private function renameInternalIdentifier(string $name): string
{
if (\str_ends_with($name, '__metadata')) {
return '<metadata>';
}
if (\str_ends_with($name, '_perms')) {
return '<permissionCheck>';
}
if (isset(self::EXPLAIN_COLUMN_RENAMES[$name])) {
return self::EXPLAIN_COLUMN_RENAMES[$name];
}
// EXPLAIN tree string-values can embed internal identifiers (e.g.
// index_condition: "main.`_uid` = '...'"). Substring-rewrite each.
// NOTE: this is a best-effort, display-only substring replace — a user
// column whose name happens to contain "_uid"/"_tenant"/etc. would be
// rewritten too. Acceptable because the output is for human reading of
// the plan, not for round-tripping back to a real column name; the raw
// `tree` is the same string pre-rename if an exact value is ever needed.
if (\str_contains($name, '_')) {
foreach (self::EXPLAIN_COLUMN_RENAMES as $internal => $public) {
if (\str_contains($name, $internal)) {
$name = \str_replace($internal, $public, $name);
}
}
}
return $name;
}

/**
* Produce a normalized query plan for a single statement.
*
* Every adapter returns the same fixed shape so the public DTO can stay
* typed regardless of engine:
* engine precise backend label (mysql|mariadb|postgres|mongo|...)
* rowsScanned estimated rows the planner expects to examine (null if N/A)
* indexUsed index the chosen access path uses, user-facing name (null if none)
* estimatedCost planner cost estimate (null if the engine has none)
* rowsReturned ACTUAL rows produced when the plan was run with ANALYZE (null if not analyzed)
* executionTime ACTUAL wall time in milliseconds under ANALYZE (null if not analyzed)
* tree raw engine plan for maximum detail
*
* @param string $sql
* @param array<string, mixed> $binds
* @return array<string, mixed>
*/
protected function explainSQL(string $sql, array $binds = []): array
{
return [
'engine' => 'unsupported',
'rowsScanned' => null,
'indexUsed' => null,
'estimatedCost' => null,
'rowsReturned' => null,
'executionTime' => null,
'tree' => null,
];
}

/**
* Set Namespace.
*
Expand Down
18 changes: 18 additions & 0 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -1957,6 +1957,24 @@ public function getConnectionId(): string
return $stmt->fetchColumn();
}

/**
* @param string $sql
* @param array<string, mixed> $binds
* @return array<string, mixed>
*/
protected function explainSQL(string $sql, array $binds = []): array
{
// setTimeout() wraps statements with `SET STATEMENT ... FOR <SQL>`,
// which is illegal inside EXPLAIN. Strip the wrapper before delegating.
$stripped = \preg_replace('/^\s*SET\s+STATEMENT\s+[^;]*?\s+FOR\s+/is', '', $sql, 1);
return parent::explainSQL($stripped ?? $sql, $binds);
}

protected function getExplainEngine(): string
{
return 'mariadb';
}

public function getInternalIndexesKeys(): array
{
return ['primary', '_created_at', '_updated_at', '_tenant_id'];
Expand Down
Loading
Loading