Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
198 changes: 198 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,176 @@ 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 real execution stats to the most recently captured plan entry.
*
* Avoids a second EXPLAIN ANALYZE pass by measuring the read that already
* runs inside the explain scope.
*
* @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];
}
// The permission/metadata tables also appear embedded inside plan
// strings — e.g. a MariaDB attached_condition like
// "`db_x_collection_y_perms`.`_permission` in (...)". The suffix checks
// above only catch standalone identifiers, so rewrite the embedded
// physical-table tokens here too. Match the longest run of
// identifier/backtick chars ending in the internal suffix.
$name = \preg_replace('/[`\w]*__metadata/', '<metadata>', $name) ?? $name;
$name = \preg_replace('/[`\w]*_perms/', '<permissionCheck>', $name) ?? $name;

// SQL plans qualify columns with the schema name (e.g. a MariaDB
// condition "`appwrite`.`main`.`status` = '...'"). Drop the leading
// schema qualifier so the internal database name is not exposed.
$database = $this->getDatabase();
if ($database !== '') {
$name = \str_replace('`'.$database.'`.', '', $name);
}

// Plan strings embed internal column identifiers (e.g. index_condition:
// "main.`_uid` = '...'"). Best-effort, display-only substring rewrite:
// a user column containing "_uid"/"_tenant"/etc. is rewritten too, which
// is fine since this is for reading the plan, not round-tripping names.
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 (engine, rowsScanned,
* indexUsed, estimatedCost, rowsReturned, executionTime, tree) so the
* public DTO stays typed regardless of engine.
*
* @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 Expand Up @@ -347,6 +533,8 @@ public function getTimeout(): int
*/
public function clearTimeout(string $event): void
{
$this->timeout = 0;

// Clear existing callback
$this->before($event, 'timeout');
}
Expand Down Expand Up @@ -1610,6 +1798,16 @@ public function getSupportForTTLIndexes(): bool
return false;
}

/**
* Is query explain (plan capture via withExplain) supported?
*
* @return bool
*/
public function getSupportForExplain(): bool
{
return false;
}

/**
* Does the adapter support transaction retries?
*
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