Skip to content
Merged
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
4 changes: 2 additions & 2 deletions internal/pg/bloat.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error {
if err != nil {
return err
}
qualified := fmt.Sprintf("%q.%q", t.Schema, t.Name)
qualified := qualifiedIdent(t.Schema, t.Name)

for i := range parts {
p := &parts[i]
Expand Down Expand Up @@ -83,7 +83,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error {
}
}
case PartIndex:
indexRef := fmt.Sprintf("%q.%q", t.Schema, p.Name)
indexRef := qualifiedIdent(t.Schema, p.Name)
if mode == BloatExact && p.AccessMethod == "btree" {
if err := pool.QueryRow(ctx, sqlBloatIndex, indexRef).Scan(&p.WastedBytes); err == nil {
p.HasBloat = true
Expand Down
94 changes: 28 additions & 66 deletions internal/pg/queries_buffers.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package pg

import "fmt"

// --- shared-buffers view ---

// sqlExtensionProbe returns two booleans: whether the named extension is
Expand Down Expand Up @@ -85,7 +87,14 @@ ORDER BY g.usagecount
// pg_buffercache.reldatabase = 0 is the shared catalog buffer pool — included
// so system relations a user owns aren't double-counted oddly, though for
// user schemas the join via relfilenode usually filters those out.
const sqlBufferStats = `
//
// sqlBufferStatsTmpl is the shared body of the two variants below, which
// differ only in how rows are picked: by schema (needs the extra pg_namespace
// join in the filenodes arms, %[1]s, and an nspname predicate) or by a single
// relation OID. %[2]s is the row predicate, %[3]s the trailing ORDER BY
// (pointless for the single-row variant). The fragments are the fixed strings
// in the var block below — nothing user-supplied is ever spliced in.
const sqlBufferStatsTmpl = `
WITH bc AS (
SELECT relfilenode,
COUNT(*) AS bufs,
Expand All @@ -97,20 +106,17 @@ WITH bc AS (
),
filenodes AS (
SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p')
FROM pg_class c%[1]s
WHERE %[2]s AND c.relkind IN ('r','m','p')
UNION ALL
SELECT c.oid, pg_relation_filenode(c.reltoastrelid)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0
FROM pg_class c%[1]s
WHERE %[2]s AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0
UNION ALL
SELECT c.oid, pg_relation_filenode(i.indexrelid)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
FROM pg_class c%[1]s
JOIN pg_index i ON i.indrelid = c.oid
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p')
WHERE %[2]s AND c.relkind IN ('r','m','p')
),
buffered AS (
SELECT f.tab_oid,
Expand All @@ -136,65 +142,21 @@ FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN buffered b ON b.tab_oid = c.oid
LEFT JOIN pg_statio_user_tables s ON s.relid = c.oid
WHERE n.nspname = $1 AND c.relkind IN ('r','m','p')
ORDER BY buffered_bytes DESC, c.relname
WHERE %[2]s AND c.relkind IN ('r','m','p')%[3]s
`

// sqlBufferStatByOID is sqlBufferStats scoped to a single relation by OID
// instead of a whole schema — the natural shape for the describe-table view,
// which has the OID but wants only that one table's cache footprint. The
// filenodes CTEs and final SELECT filter on c.oid = $1; the rest matches
// sqlBufferStats so the scanned column list is identical.
const sqlBufferStatByOID = `
WITH bc AS (
SELECT relfilenode,
COUNT(*) AS bufs,
COUNT(*) FILTER (WHERE isdirty) AS dirty_bufs,
SUM(usagecount)::bigint AS usage_sum
FROM pg_buffercache
WHERE reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))
GROUP BY relfilenode
),
filenodes AS (
SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn
FROM pg_class c
WHERE c.oid = $1 AND c.relkind IN ('r','m','p')
UNION ALL
SELECT c.oid, pg_relation_filenode(c.reltoastrelid)
FROM pg_class c
WHERE c.oid = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0
UNION ALL
SELECT c.oid, pg_relation_filenode(i.indexrelid)
FROM pg_class c
JOIN pg_index i ON i.indrelid = c.oid
WHERE c.oid = $1 AND c.relkind IN ('r','m','p')
),
buffered AS (
SELECT f.tab_oid,
COALESCE(SUM(bc.bufs), 0)::bigint AS bufs,
COALESCE(SUM(bc.dirty_bufs), 0)::bigint AS dirty_bufs,
COALESCE(SUM(bc.usage_sum), 0)::bigint AS usage_sum
FROM filenodes f
LEFT JOIN bc ON bc.relfilenode = f.fn
GROUP BY f.tab_oid
var (
// sqlBufferStats lists every table in one schema ($1 = nspname).
sqlBufferStats = fmt.Sprintf(sqlBufferStatsTmpl,
"\n JOIN pg_namespace n ON n.oid = c.relnamespace",
"n.nspname = $1",
"\nORDER BY buffered_bytes DESC, c.relname")
// sqlBufferStatByOID is the same query scoped to a single relation
// ($1 = oid) — the natural shape for the describe-table view, which has
// the OID but wants only that one table's cache footprint. The scanned
// column list is identical to sqlBufferStats.
sqlBufferStatByOID = fmt.Sprintf(sqlBufferStatsTmpl, "", "c.oid = $1", "")
)
SELECT c.oid,
n.nspname,
c.relname,
COALESCE(b.bufs, 0) * current_setting('block_size')::int AS buffered_bytes,
pg_total_relation_size(c.oid) AS total_bytes,
COALESCE(s.heap_blks_hit, 0) + COALESCE(s.idx_blks_hit, 0) AS hits,
COALESCE(s.heap_blks_read, 0) + COALESCE(s.idx_blks_read, 0) AS reads,
COALESCE(b.dirty_bufs, 0) * current_setting('block_size')::int AS dirty_bytes,
CASE WHEN COALESCE(b.bufs, 0) > 0
THEN b.usage_sum::float8 / b.bufs
ELSE 0 END AS usage_avg
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN buffered b ON b.tab_oid = c.oid
LEFT JOIN pg_statio_user_tables s ON s.relid = c.oid
WHERE c.oid = $1 AND c.relkind IN ('r','m','p')
`

// sqlShmemAllocations dumps the whole Postgres shared-memory segment from
// pg_shmem_allocations: every named region (the buffer pool, lock tables, SLRU
Expand Down
2 changes: 1 addition & 1 deletion internal/pg/reindex.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func (c *Client) ReindexIndex(ctx context.Context, t Table, indexName string) er
if err != nil {
return err
}
stmt := fmt.Sprintf("REINDEX INDEX CONCURRENTLY %q.%q", t.Schema, indexName)
stmt := "REINDEX INDEX CONCURRENTLY " + qualifiedIdent(t.Schema, indexName)
if _, err := pool.Exec(ctx, stmt); err != nil {
return fmt.Errorf("reindex %q.%q: %w", t.Schema, indexName, err)
}
Expand Down
34 changes: 34 additions & 0 deletions internal/pg/statements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,40 @@ func TestQueryStatHitRatioAndDerived(t *testing.T) {
}
}

func TestQueryStatDerivedEdgeCases(t *testing.T) {
// BlocksPerRow: undefined without result rows, defined otherwise.
if _, ok := (QueryStat{SharedBlksHit: 10}).BlocksPerRow(); ok {
t.Error("BlocksPerRow should be undefined with zero rows")
}
if _, ok := (QueryStat{Rows: -1, SharedBlksHit: 10}).BlocksPerRow(); ok {
t.Error("BlocksPerRow should be undefined with negative rows")
}
if bpr, ok := (QueryStat{Rows: 4, SharedBlksHit: 6, SharedBlksRead: 2}).BlocksPerRow(); !ok || bpr != 2 {
t.Errorf("BlocksPerRow = %v ok=%v, want 2 true", bpr, ok)
}

// IOTime sums all six timing counters; zero when none are set.
if got := (QueryStat{}).IOTime(); got != 0 {
t.Errorf("IOTime zero = %v, want 0", got)
}
full := QueryStat{
SharedBlkReadTime: 1, SharedBlkWriteTime: 2,
LocalBlkReadTime: 4, LocalBlkWriteTime: 8,
TempBlkReadTime: 16, TempBlkWriteTime: 32,
}
if got := full.IOTime(); got != 63 {
t.Errorf("IOTime = %v, want 63", got)
}

// HitRatio boundaries: all-hit and all-miss are both defined ratios.
if hr, ok := (QueryStat{SharedBlksHit: 5}).HitRatio(); !ok || hr != 100 {
t.Errorf("HitRatio all-hit = %v ok=%v, want 100 true", hr, ok)
}
if hr, ok := (QueryStat{SharedBlksRead: 5}).HitRatio(); !ok || hr != 0 {
t.Errorf("HitRatio all-miss = %v ok=%v, want 0 true", hr, ok)
}
}

func TestBuildSampleCall(t *testing.T) {
cases := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion internal/pg/vacuum.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (c *Client) VacuumTable(ctx context.Context, t Table, onLine func(string))
}
defer func() { _ = conn.Close(context.Background()) }()
_, _ = conn.Exec(ctx, "SET pg_stat_statements.track = 'none'")
stmt := fmt.Sprintf("VACUUM (VERBOSE, ANALYZE, SKIP_LOCKED) %q.%q", t.Schema, t.Name)
stmt := "VACUUM (VERBOSE, ANALYZE, SKIP_LOCKED) " + qualifiedIdent(t.Schema, t.Name)
if _, err := conn.Exec(ctx, stmt); err != nil {
return fmt.Errorf("vacuum %q.%q: %w", t.Schema, t.Name, err)
}
Expand Down
8 changes: 8 additions & 0 deletions internal/tui/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ func (s *screen) clampCursor() {
}
}

// resetCursor jumps the selection back to the top of the list. cursor and
// offset move together so the viewport can't be left scrolled past the
// selection.
func (s *screen) resetCursor() {
s.cursor = 0
s.offset = 0
}

// viewportRange adjusts offset so cursor stays inside [offset, offset+height)
// and returns the new offset plus the half-open end. Callers use the offset
// to scroll the list and the end (clamped to the underlying length elsewhere)
Expand Down
36 changes: 35 additions & 1 deletion internal/tui/filter_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package tui

import "testing"
import (
"strings"
"testing"
)

func TestFuzzyMatch(t *testing.T) {
cases := []struct {
Expand Down Expand Up @@ -42,6 +45,33 @@ func TestSubstringMatch(t *testing.T) {
}
}

// TestContainsFold checks the allocation-free fast path against the
// lowercased strings.Contains it replaces, including the non-ASCII needle
// that must take the unicode-correct fallback.
func TestContainsFold(t *testing.T) {
cases := []struct{ s, substr string }{
{"SELECT * FROM Users", "users"},
{"select * from users", "USERS"},
{"select * from users", "orders"},
{"", ""},
{"abc", ""},
{"", "a"},
{"ab", "abc"}, // needle longer than haystack
{"alpha", "a"},
{"alpha", "A"},
{"alph", "ph"}, // match at the very end
{"NAÏVE query", "naïve"}, // non-ASCII needle → fallback path
{"multibyte żółć in the haystack", "haystack"}, // ASCII needle over UTF-8 haystack
{"żółć", "x"},
}
for _, c := range cases {
want := strings.Contains(strings.ToLower(c.s), strings.ToLower(c.substr))
if got := containsFold(c.s, c.substr); got != want {
t.Errorf("containsFold(%q, %q) = %v, want %v", c.s, c.substr, got, want)
}
}
}

func TestMatchFilterPerLevel(t *testing.T) {
// b…a…t…t…l…e is a subsequence of this query but not a substring.
const q = "select b1_0.id, b1_0.created_at from player_state where a > $1"
Expand All @@ -68,6 +98,10 @@ func TestViewportRange(t *testing.T) {
{"cursor above window scrolls up", 1, 5, 10, 20, 1, 11},
{"end clamped to length", 0, 0, 10, 4, 0, 4},
{"negative offset clamped", 0, -3, 10, 20, 0, 10},
{"cursor on last item", 19, 0, 10, 20, 10, 20},
{"cursor exactly at viewport tail", 9, 0, 10, 20, 0, 10},
{"list shorter than viewport", 2, 0, 10, 3, 0, 3},
{"height one pins offset to cursor", 7, 3, 1, 20, 7, 8},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
Expand Down
67 changes: 65 additions & 2 deletions internal/tui/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"fmt"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -46,8 +47,8 @@ func positionLabel(s *screen) string {
}

// bloatScanLabel returns a short status indicator for the bloat fetch on
// the parts level. FillBloat is a single round trip that covers every
// part, so the states are "scanning…" (in flight) or "ready" (done) —
// the parts level. FillBloat is a single Cmd that covers every part, so
// the states are "scanning…" (in flight) or "ready" (done) —
// any partial scanned count comes from individual rows whose bloat could
// not be measured (e.g. unsupported index access methods).
func bloatScanLabel(s *screen) string {
Expand Down Expand Up @@ -162,3 +163,65 @@ func maxInt(a, b int) int {
}
return b
}

// fmtFloat renders a number with up to 1 decimals, trailing zeros stripped.
func fmtFloat(f float64) string {
s := strconv.FormatFloat(f, 'f', 1, 64)
if strings.ContainsRune(s, '.') {
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
}
return s
}

// fmt1 renders a number with exactly one decimal place (60 → "60.0", 98.51 →
// "98.5"). The top-queries numeric columns use it so every value shows a single
// fractional digit rather than a ragged mix of 0/1/2 places.
func fmt1(f float64) string {
return strconv.FormatFloat(f, 'f', 1, 64)
}

// fmtMs formats a millisecond duration compactly: sub-millisecond and small
// values keep ms; large values switch to seconds so the column stays narrow.
func fmtMs(ms float64) string {
if ms >= 100000 {
return fmt1(ms/1000) + "s"
}
return fmt1(ms)
}

// fmtAge formats an elapsed time (in ms) as an age with an explicit, scale-
// appropriate unit so values never read ambiguously: "850ms", "31.1s", "11.2m",
// "3.1h", "2.4d". Unlike fmtMs the unit is always present, which is what lets the
// reader tell 105ms from 105s at a glance (paired with durationStyle colouring).
func fmtAge(ms float64) string {
switch {
case ms < 1000:
return fmt.Sprintf("%.0fms", ms)
case ms < 60*1000:
return fmt1(ms/1000) + "s"
case ms < 60*60*1000:
return fmt1(ms/(60*1000)) + "m"
case ms < 24*60*60*1000:
return fmt1(ms/(60*60*1000)) + "h"
default:
return fmt1(ms/(24*60*60*1000)) + "d"
}
}

// fmtDuration renders a window span with explicit units — "45s", "13m 12s",
// "2h 05m", "3d 4h" — so it never reads as a wall-clock time. The old H:MM:SS
// form made "13:12" ambiguous with a start timestamp, which is the whole reason
// it sits next to "since 05:56:46".
func fmtDuration(d time.Duration) string {
d = d.Round(time.Second)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm %02ds", int(d/time.Minute), int(d%time.Minute/time.Second))
case d < 24*time.Hour:
return fmt.Sprintf("%dh %02dm", int(d/time.Hour), int(d%time.Hour/time.Minute))
default:
return fmt.Sprintf("%dd %dh", int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour))
}
}
Loading
Loading