add Pg table viewer from list

This commit is contained in:
Spine
2025-05-03 03:15:10 +00:00
parent 5e9e3384a8
commit 42df98c9da
11 changed files with 523 additions and 52 deletions

40
app/DB/AbstractTable.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Gazelle\DB;
abstract class AbstractTable extends \Gazelle\BaseObject {
public function __construct(
public readonly string $name,
) {}
public function link(): string {
return "<a href=\"{$this->url()}\">{$this->name}</a>";
}
/* The usual design pattern would be to have a Table manager
* and look up the table by name. But that would add a fair
* amount of effort for little gain, so instead an exists()
* method can be used to validate the object does in fact
* point a real table.
*/
abstract public function exists(): bool;
/* The CREATE TABLE string */
abstract public function definition(): string;
/* metadata about index reads */
abstract public function indexRead(): array;
/* metadata about table reads */
abstract public function tableRead(): array;
/* general statistics of the table */
abstract public function stats(): array;
/* none of the derived classes perform any caching */
public function flush(): static {
return $this;
}
}

87
app/DB/MysqlTable.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Gazelle\DB;
class MysqlTable extends AbstractTable {
protected Mysql $dbro;
public function __construct(
public readonly string $name,
) {
$this->dbro = \Gazelle\DB::DB(readWrite: false);
}
public function location(): string {
return "tools.php?action=db-mysql&table={$this->name}";
}
public function exists(): bool {
return (bool)self::$db->scalar("
SELECT 1
FROM information_schema.tables t
WHERE t.table_schema = ?
AND t.table_name = ?
", MYSQL_DB, $this->name
);
}
public function definition(): string {
return self::$db->row("SHOW CREATE TABLE {$this->name}")[1];
}
public function indexRead(): array {
self::$db->prepared_query("
SELECT s.INDEX_NAME AS index_name,
coalesce(si.ROWS_READ, 0) AS rows_read,
group_concat(
concat(s.column_name, ' {', s.cardinality, '}')
ORDER BY s.seq_in_index
SEPARATOR ', '
) AS column_list
FROM information_schema.statistics s
LEFT JOIN information_schema.index_statistics si
USING (TABLE_SCHEMA, TABLE_NAME, INDEX_NAME)
WHERE s.TABLE_SCHEMA = ?
AND s.TABLE_NAME = ?
GROUP BY index_name,
rows_read
ORDER BY s.TABLE_NAME,
s.INDEX_NAME = 'PRIMARY' DESC,
coalesce(si.ROWS_READ, 0) DESC,
s.INDEX_NAME
", MYSQL_DB, $this->name
);
return self::$db->to_array(false, MYSQLI_ASSOC, false);
}
public function tableRead(): array {
return self::$db->rowAssoc("
SELECT ROWS_READ, ROWS_CHANGED, ROWS_CHANGED_X_INDEXES
FROM information_schema.table_statistics
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
", MYSQL_DB, $this->name
);
}
public function stats(): array {
return self::$db->rowAssoc("
SELECT t.TABLE_ROWS,
t.AVG_ROW_LENGTH,
t.DATA_LENGTH,
t.INDEX_LENGTH,
t.DATA_FREE,
ts.ROWS_READ,
ts.ROWS_CHANGED,
ts.ROWS_CHANGED_X_INDEXES
FROM information_schema.tables t
INNER JOIN information_schema.table_statistics ts
USING (TABLE_SCHEMA, TABLE_NAME)
WHERE t.TABLE_SCHEMA = ?
AND t.TABLE_NAME = ?
", MYSQL_DB, $this->name
);
}
}

156
app/DB/PgTable.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Gazelle\DB;
class PgTable extends AbstractTable {
public function location(): string {
return "tools.php?action=db-pg&table={$this->name}";
}
protected function schemaTable(): array {
/* If we have 'schemaname.tablename', split them apart and
* return them separately. Otherwise we have only a table
* name, which is assumed to be in the public schema.
*/
$all = explode('.', $this->name, 2);
if (count($all) === 2) {
return $all;
}
return ['public', $this->name];
}
public function exists(): bool {
[$schema, $table] = $this->schemaTable();
return (bool)$this->pg()->scalar("
SELECT 1
FROM information_schema.tables t
WHERE t.table_schema = ?
AND t.table_name = ?
", $schema, $table
);
}
public function definition(): string {
[$schema, $table] = $this->schemaTable();
/* Generating a create table in Postgresql is non-trivial
* unless you have a pg_dump binary handy (and wish to
* spawn a child process). So we use some code a person
* on the internet wrote, and bend it to our needs.
*/
return implode(
"\n",
$this->pgro()->column("
with pkey as (
select cc.conrelid,
format(E',
constraint %I primary key(%s)', cc.conname,
string_agg(a.attname, ', '
order by array_position(cc.conkey, a.attnum))
) pkey
from pg_catalog.pg_constraint cc
inner join pg_catalog.pg_class c on (c.oid = cc.conrelid)
inner join pg_catalog.pg_attribute a on (
a.attrelid = cc.conrelid and a.attnum = any(cc.conkey)
)
where cc.contype = 'p'
group by cc.conrelid, cc.conname
)
select format(E'create %stable %s%I (\n%s%s\n);\n',
case c.relpersistence when 't' then 'temporary ' else '' end,
case c.relpersistence when 't' then '' else n.nspname || '.' end,
c.relname,
string_agg(
format(E'\t%I %s%s',
a.attname,
pg_catalog.format_type(a.atttypid, a.atttypmod),
case when a.attnotnull then ' not null' else '' end
), E',\n'
order by a.attnum
),
(select pkey from pkey where pkey.conrelid = c.oid)
) as sql
from pg_catalog.pg_class c
inner join pg_catalog.pg_namespace n on (n.oid = c.relnamespace)
inner join pg_catalog.pg_attribute a on (a.attrelid = c.oid and a.attnum > 0)
inner join pg_catalog.pg_type t on (a.atttypid = t.oid)
where n.nspname = ?
and c.relname = ?
group by c.oid, c.relname, c.relpersistence, n.nspname;
", $schema, $table
)
);
}
public function indexRead(): array {
[$schema, $table] = $this->schemaTable();
return $this->pgro()->all("
select indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
last_idx_scan
from pg_stat_all_indexes
where schemaname = ?
and relname = ?
", $schema, $table
);
}
public function tableRead(): array {
[$schema, $table] = $this->schemaTable();
return $this->pgro()->rowAssoc("
select seq_scan,
last_seq_scan,
seq_tup_read,
idx_scan,
last_idx_scan,
idx_tup_fetch,
n_tup_ins,
n_tup_upd,
n_tup_del,
n_tup_hot_upd,
n_tup_newpage_upd,
n_live_tup,
n_dead_tup,
n_ins_since_vacuum,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count,
n_mod_since_analyze,
analyze_count,
autoanalyze_count
from pg_stat_user_tables
where schemaname = ?
and relname = ?
", $schema, $table
);
}
public function stats(): array {
[$schema, $table] = $this->schemaTable();
return $this->pg()->rowAssoc("
select pg_relation_size(t.table_schema || '.' || t.table_name) as table_size,
pg_indexes_size(t.table_schema || '.' || t.table_name) as index_size,
s.n_live_tup as live,
s.n_dead_tup as dead,
case when s.n_dead_tup + s.n_live_tup = 0
then 0
else round(s.n_dead_tup/(s.n_dead_tup + n_live_tup*1.0), 5)
end as dead_ratio,
now() - s.last_autoanalyze as analyze_delta,
now() - s.last_autovacuum as vacuum_delta,
s.autoanalyze_count as analyze_total,
s.autovacuum_count as vacuum_total
from information_schema.tables t
inner join pg_stat_user_tables s on (
s.schemaname = t.table_schema and s.relname = t.table_name
)
where t.table_schema = ?
and t.table_name = ?
", $schema, $table
);
}
}

View File

@@ -6,40 +6,30 @@ declare(strict_types=1);
namespace Gazelle;
use Gazelle\Enum\Direction;
use Gazelle\Enum\MysqlInfoOrderBy;
use Gazelle\Enum\MysqlTableMode;
if (!$Viewer->permitted('site_database_specifics')) {
Error403::error();
}
// View table definition
$db = DB::DB();
if (!empty($_GET['table']) && preg_match('/([\w-]+)/', $_GET['table'], $match)) {
$tableName = $match[1];
$siteInfo = new SiteInfo();
if (!$siteInfo->tableExists($tableName)) {
Error404::error("No such table");
if (preg_match('/([\w-]+)/', $_GET['table'] ?? '', $match)) {
$table = new DB\MysqlTable($match[1]);
if (!$table->exists()) {
Error404::error("No such Mysql table {$match[1]}");
}
echo $Twig->render('admin/mysql-table.twig', [
'definition' => $db->row('SHOW CREATE TABLE ' . $tableName)[1],
'table_name' => $tableName,
'table_read' => $siteInfo->tableRowsRead($tableName),
'index_read' => $siteInfo->indexRowsRead($tableName),
'stats' => $siteInfo->tableStats($tableName),
'table' => $table,
]);
exit;
}
$info = (new DB\MysqlInfo(
DB\MysqlInfo::lookupTableMode($_GET['mode'] ?? MysqlTableMode::all->value),
DB\MysqlInfo::lookupOrderby($_GET['order'] ?? MysqlInfoOrderBy::tableName->value),
DB::lookupDirection($_GET['sort'] ?? Direction::ascending->value))
DB\MysqlInfo::lookupTableMode($_GET['mode'] ?? Enum\MysqlTableMode::all->value),
DB\MysqlInfo::lookupOrderby($_GET['order'] ?? Enum\MysqlInfoOrderBy::tableName->value),
DB::lookupDirection($_GET['sort'] ?? Enum\Direction::ascending->value))
);
$list = $info->info();
$column = $info->orderBy() == MysqlInfoOrderBy::tableName
? MysqlInfoOrderBy::tableRows->value
$column = $info->orderBy() == Enum\MysqlInfoOrderBy::tableName
? Enum\MysqlInfoOrderBy::tableRows->value
: $info->orderBy()->value;
$data = [];
foreach ($list as $t) {
@@ -48,7 +38,7 @@ foreach ($list as $t) {
echo $Twig->render('admin/mysql-table-summary.twig', [
'header' => new Util\SortableTableHeader(
MysqlInfoOrderBy::tableName->value,
Enum\MysqlInfoOrderBy::tableName->value,
DB\MysqlInfo::columnList(),
),
'list' => $list,

View File

@@ -6,21 +6,30 @@ declare(strict_types=1);
namespace Gazelle;
use Gazelle\Enum\PgInfoOrderBy;
use Gazelle\Enum\Direction;
if (!$Viewer->permitted('site_database_specifics')) {
Error403::error();
}
// View table definition
if (preg_match('/([\w-]+(?:\.[\w-]+)?)/', $_GET['table'] ?? '', $match)) {
$table = new DB\PgTable($match[1]);
if (!$table->exists()) {
Error404::error("No such Postgresql table {$match[1]}");
}
echo $Twig->render('admin/pg-table.twig', [
'table' => $table,
]);
exit;
}
$info = new DB\PgInfo(
DB\PgInfo::lookupOrderby($_GET['order'] ?? PgInfoOrderBy::tableName->value),
DB::lookupDirection($_GET['sort'] ?? Direction::ascending->value)
DB\PgInfo::lookupOrderby($_GET['order'] ?? Enum\PgInfoOrderBy::tableName->value),
DB::lookupDirection($_GET['sort'] ?? Enum\Direction::ascending->value)
);
echo $Twig->render('admin/pg-table-summary.twig', [
'header' => new Util\SortableTableHeader(
PgInfoOrderBy::tableName->value,
Enum\PgInfoOrderBy::tableName->value,
DB\PgInfo::columnList()
),
'list' => $info->info(),

View File

@@ -1,4 +1,4 @@
{{ header('Database Specifics - ' ~ table_name) }}
{{ header('Database Specifics - ' ~ table.name) }}
<div class="linkbox">
<a href="tools.php?action=service_stats" class="brackets">Cache/DB stats</a>
<a href="tools.php?action=clear_cache" class="brackets">Cache inspector</a>
@@ -8,9 +8,9 @@
</div>
<div class="pad"><div class="box pad">
<h3>Table {{ table_name }} definition</h3>
<pre>{{ definition }}</pre>
<a href="tools.php?action=db_sandbox&amp;table={{ table_name }}" class="brackets">Inspect</a>
<h3>Mysql table {{ table.name }} definition</h3>
<pre>{{ table.definition }}</pre>
<a href="tools.php?action=db_sandbox&amp;table={{ table.name }}" class="brackets">Inspect</a>
</div></div>
<div class="pad"><div class="box pad">
@@ -26,18 +26,16 @@
<th>Rows changed</th>
<th>Rows changed per index</th>
</tr>
{% for r in table_read %}
<tr>
<td>{{ stats.TABLE_ROWS|number_format }}</td>
<td>{{ stats.AVG_ROW_LENGTH|number_format }}</td>
<td>{{ stats.DATA_LENGTH|octet_size }}</td>
<td>{{ stats.INDEX_LENGTH|octet_size }}</td>
<td>{{ stats.DATA_FREE|octet_size }}</td>
<td>{{ stats.ROWS_READ|number_format }}</td>
<td>{{ stats.ROWS_CHANGED|number_format }}</td>
<td>{{ stats.ROWS_CHANGED_X_INDEXES|number_format }}</td>
<td>{{ table.stats.TABLE_ROWS|number_format }}</td>
<td>{{ table.stats.AVG_ROW_LENGTH|number_format }}</td>
<td>{{ table.stats.DATA_LENGTH|octet_size }}</td>
<td>{{ table.stats.INDEX_LENGTH|octet_size }}</td>
<td>{{ table.stats.DATA_FREE|octet_size }}</td>
<td>{{ table.stats.ROWS_READ|number_format }}</td>
<td>{{ table.stats.ROWS_CHANGED|number_format }}</td>
<td>{{ table.stats.ROWS_CHANGED_X_INDEXES|number_format }}</td>
</tr>
{% endfor %}
</table>
</div></div>
@@ -49,7 +47,7 @@
<th>Rows read</th>
<th>Column list and cardinalities</th>
</tr>
{% for r in index_read %}
{% for r in table.indexRead %}
<tr>
<td>{{ r.index_name }}</td>
<td>{{ r.rows_read|number_format }}</td>

View File

@@ -7,7 +7,7 @@
</div>
<div class="pad"><div class="box pad">
<h3>Rows read</h3>
<h3>Postgresql tables</h3>
<table>
<tr>
<th>{{ header|column('table_name') }}</th>
@@ -23,7 +23,8 @@
</tr>
{% for r in list %}
<tr>
<td>{{ r.table_name }}</td>
<td><a href="tools.php?action=db-pg&amp;table={{ r.table_name }}">{{
r.table_name }}</a></td>
<td>{{ r.table_size|octet_size }}</td>
<td>{{ r.index_size|octet_size }}</td>
<td>{{ r.live|number_format }}</td>

View File

@@ -0,0 +1,70 @@
{{ header('Database Specifics - Postgresql ' ~ table.name) }}
<div class="linkbox">
<a href="tools.php?action=service_stats" class="brackets">Cache/DB stats</a>
<a href="tools.php?action=clear_cache" class="brackets">Cache inspector</a>
<a href="tools.php?action=db-mysql" class="brackets">Mysql inspector</a>
<a href="tools.php?action=db-pg" class="brackets">Postgresql inspector</a>
<a href="tools.php?action=db_sandbox" class="brackets">DB sandbox</a>
</div>
<div class="pad"><div class="box pad">
<h3>Postgresql table {{ table.name }} definition</h3>
<pre>{{ table.definition }}</pre>
<a href="tools.php?action=db_sandbox&amp;src=pg&amp;table={{ table.name }}" class="brackets">Inspect</a>
</div></div>
<div class="pad"><div class="box pad">
<h3>Rows read</h3>
<table>
<tr>
<th>Live tuples</th>
<th>Dead tuples</th>
<th>Ratio</th>
<th>Table</th>
<th>Index</th>
<th>Analyzed</th>
<th>Vacuumed</th>
<th>Analyze total</th>
<th>Vacuum total</th>
</tr>
<tr>
<td>{{ table.stats.live|number_format }}</td>
<td>{{ table.stats.dead|number_format }}</td>
<td>{{ (table.stats.dead == 0
? 0
: (table.stats.dead / table.stats.live) * 100)|number_format(2)
}}</td>
<td>{{ table.stats.table_size|octet_size }}</td>
<td>{{ table.stats.index_size|octet_size }}</td>
<td>{{ table.stats.analyze_delta|time_diff }}</td>
<td>{{ table.stats.vacuum_delta|time_diff }}</td>
<td>{{ table.stats.analyze_total|number_format }}</td>
<td>{{ table.stats.vacuum_total|number_format }}</td>
</tr>
</table>
</div></div>
<div class="pad"><div class="box pad">
<h3>Index reads</h3>
<table>
<tr>
<th>Index name</th>
<th>Calls</th>
<th>Tuples read</th>
<th>Tuples fetched</th>
<th>Last use</th>
<th>Column list and cardinalities</th>
</tr>
{% for r in table.indexRead %}
<tr>
<td>{{ r.indexrelname }}</td>
<td>{{ r.idx_scan|number_format }}</td>
<td>{{ r.idx_tup_read|number_format }}</td>
<td>{{ r.idx_tup_fetch|number_format }}</td>
<td>{{ r.last_idx_scan|time_diff }}</td>
<td>todo</td>
</tr>
{% endfor %}
</table>
</div></div>
{{ footer() }}

View File

@@ -5,7 +5,6 @@ namespace Gazelle;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Group;
use GazelleUnitTest\Helper;
use Gazelle\Enum\Direction;
class DbTest extends TestCase {
use Pg;
@@ -279,6 +278,121 @@ class DbTest extends TestCase {
$this->assertArrayHasKey('epoch', $errorList[0], 'db-pg-error-epoch');
}
public function testMysqlDuplicateException(): void {
$this->expectException(DB\MysqlDuplicateKeyException::class);
DB::DB()->prepared_query("
INSERT INTO users_main
(ID, Username, Email, PassHash, torrent_pass, IP, PermissionID, Enabled, Invites, ipcc, auth_key, stylesheet_id)
VALUES (1, 'phpunit', '', '', '', '', 0, '0', 0, '', '', 0)
");
}
public function testMysqlTable(): void {
$bad = new DB\MysqlTable('nosuchtable');
$this->assertFalse($bad->exists(), 'mysql-table-does-not-exist');
$table = new DB\MysqlTable('users_main');
$this->assertTrue($table->exists(), 'mysql-table-exists');
$this->assertEquals(
"tools.php?action=db-mysql&table={$table->name}",
$table->location(),
'mysql-table-location',
);
$this->assertEquals(
"<a href=\"tools.php?action=db-mysql&amp;table=users_main\">users_main</a>",
$table->link(),
'mysql-table-link',
);
$this->assertStringStartsWith(
'CREATE TABLE `' . $table->name . '` (',
$table->definition(),
'mysql-table-definition',
);
// if this fails, check the permissions on information_schema.table_statistics
$this->assertEquals(
[
"ROWS_READ", "ROWS_CHANGED", "ROWS_CHANGED_X_INDEXES",
],
array_keys($table->tableRead()),
'mysql-table-table-read',
);
$this->assertEquals(
[
"index_name", "rows_read", "column_list",
],
array_keys($table->indexRead()[0]),
'mysql-table-index-read',
);
$this->assertEquals(
[
"TABLE_ROWS", "AVG_ROW_LENGTH", "DATA_LENGTH", "INDEX_LENGTH",
"DATA_FREE", "ROWS_READ", "ROWS_CHANGED", "ROWS_CHANGED_X_INDEXES",
],
array_keys($table->stats()),
'mysql-table-stats',
);
}
public function testPgTable(): void {
$bad = new DB\PgTable('nosuchtable');
$this->assertFalse($bad->exists(), 'pg-table-does-not-exist');
$table = new DB\PgTable('user_audit_trail');
$this->assertTrue($table->exists(), 'pg-table-exists');
$this->assertEquals(
"tools.php?action=db-pg&table={$table->name}",
$table->location(),
'pg-table-location',
);
$this->assertEquals(
"<a href=\"tools.php?action=db-pg&amp;table=user_audit_trail\">user_audit_trail</a>",
$table->link(),
'pg-table-link',
);
$this->assertStringStartsWith(
"create table public.{$table->name} (",
$table->definition(),
'pg-table-definition',
);
$this->assertEquals(
[
"seq_scan", "last_seq_scan", "seq_tup_read", "idx_scan",
"last_idx_scan", "idx_tup_fetch", "n_tup_ins", "n_tup_upd",
"n_tup_del", "n_tup_hot_upd", "n_tup_newpage_upd",
"n_live_tup", "n_dead_tup", "n_ins_since_vacuum",
"last_vacuum", "last_autovacuum", "vacuum_count",
"autovacuum_count", "n_mod_since_analyze", "analyze_count",
"autoanalyze_count",
],
array_keys($table->tableRead()),
'pg-table-table-read',
);
$indexRead = $table->indexRead();
$this->assertCount(4, $indexRead, 'pg-table-index-read-total');
$index = $indexRead[0];
$this->assertEquals(
[
"indexrelname", "idx_scan", "idx_tup_read",
"idx_tup_fetch", "last_idx_scan",
],
array_keys($index),
'pg-table-index-read'
);
$this->assertEquals(
[
"table_size", "index_size", "live", "dead", "dead_ratio",
"analyze_delta", "vacuum_delta", "analyze_total",
"vacuum_total",
],
array_keys($table->stats()),
'pg-table-stats',
);
}
public function testPgByteaScalar(): void {
$this->pg()->prepared_query("
create temporary table test_bytea (

View File

@@ -14,6 +14,10 @@ class MysqlInfoTest extends TestCase {
$this->assertEquals(MysqlInfoOrderBy::tableName, DB\MysqlInfo::lookupOrderby('wut'), 'mysqlfo-orderby-default');
}
public function testMysqlInfoColumn(): void {
$this->assertCount(9, DB\MysqlInfo::columnList(), 'myinfo-column-list');
}
public function testMysqlInfoList(): void {
$mysqlInfo = new DB\MysqlInfo(
MysqlTableMode::all,

View File

@@ -3,25 +3,23 @@
namespace Gazelle;
use PHPUnit\Framework\TestCase;
use Gazelle\Enum\Direction;
use Gazelle\Enum\PgInfoOrderBy;
class PgInfoTest extends TestCase {
use Pg;
public function testDirection(): void {
$this->assertEquals(
PgInfoOrderBy::tableSize,
Enum\PgInfoOrderBy::tableSize,
DB\PgInfo::lookupOrderby('table_size'),
'pginfo-orderby-tablesize'
);
$this->assertEquals(
PgInfoOrderBy::tableName,
Enum\PgInfoOrderBy::tableName,
DB\PgInfo::lookupOrderby('table_name'),
'pginfo-orderby-tablename'
);
$this->assertEquals(
PgInfoOrderBy::tableName,
Enum\PgInfoOrderBy::tableName,
DB\PgInfo::lookupOrderby('wut'),
'pginfo-orderby-default'
);
@@ -29,13 +27,17 @@ class PgInfoTest extends TestCase {
public function testPgInfoList(): void {
$pgInfo = new DB\PgInfo(
PgInfoOrderBy::tableName,
Direction::descending,
Enum\PgInfoOrderBy::tableName,
Enum\Direction::descending,
);
$list = $pgInfo->info();
$this->assertEquals('public.user_warning', $list[0]['table_name'], 'pginfo-list');
}
public function testPgInfoColumn(): void {
$this->assertCount(10, DB\PgInfo::columnList(), 'pginfo-column-list');
}
public function testCheckpointInfo(): void {
$info = $this->pg()->checkpointInfo();
$this->assertCount(3, $info);