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: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,10 @@ about being untranslated rather than absent.
Yes, please. [CONTRIBUTING.md](CONTRIBUTING.md) has how to build it, how to run each
part of the test suite, and how behaviour gets proposed before it is built.

New to the codebase and want the map before the process? [`docs/guide/`](docs/guide/README.md)
walks through the architecture, the pipeline and the safety rules a layer at a time, each
page linking back to [`docs/PGFATHOM.md`](docs/PGFATHOM.md) and the specs for full detail.

The three most valuable things you can send:

- **A verdict that is wrong.** A confirmed false positive is treated as a blocking
Expand Down
13 changes: 13 additions & 0 deletions docs/guide/01-overview/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 01 · Overview

← [Guide](../README.md)

Start here if this is your first time in the project.

| Page | Covers |
|---|---|
| [The problem](the-problem.md) | Why legacy schemas lose structure, and what it costs |
| [What it does](what-it-does.md) | The two commands, the five verdicts, a real output example |

Next: [02 · Architecture](../02-architecture/README.md) if you're about to touch code, or
straight to [04 · Usage](../04-usage/README.md) if you just want to run the tool.
47 changes: 47 additions & 0 deletions docs/guide/01-overview/the-problem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# The problem

← [Guide](../README.md) › [Overview](README.md)

Old, large PostgreSQL databases carry more structure than they declare. Years of
maintenance, different teams, and botched migrations strip away whatever gave the schema
its meaning.

## The common symptom: a missing foreign key

```
=# \d order
Column | Type | Nullable
-----------------+--------+----------
id | bigint | not null
customer_id | bigint | not null
```

`customer_id` points at `customer.id` in **every row** — but no constraint says so.
Typical reasons, legitimate and not: someone disabled constraints for a bulk load and
never turned them back on, the ORM of the day never created it, a migration from an even
older database brought only the tables, or nobody thought it mattered.

## The cost, which shows up later

- `\d table` shows no relationship at all — nobody new to the project can read the model
- an ERD tool draws a page of disconnected boxes
- a code generator produces structs with no association
- worst: without the constraint, the database never stopped orphan rows from getting in —
so they probably already did, silently, years ago

## The sneakier variant: `NOT VALID`

A constraint can be **declared** and still guarantee nothing, if it was created `NOT
VALID` and never validated. Postgres starts blocking *new* violations, but never checked
the rows that were already there. `\d` shows the FK, the ERD tool draws the arrow,
everyone assumes integrity — and the old orphans stay invisible.

That's exactly the second finding `pgfathom audit` looks for — see
[What it does](what-it-does.md).

## Full detail

The design record goes further into this — the "layer of lost knowledge" beyond foreign
keys (inconsistent soft-delete, missing tenant columns, `varchar` acting as an
undeclared enum) that's out of scope for the v0.1 but shaped the roadmap. See
[`docs/PGFATHOM.md` § O problema](../../PGFATHOM.md#o-problema) (Portuguese).
46 changes: 46 additions & 0 deletions docs/guide/01-overview/what-it-does.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# What it does

← [Guide](../README.md) › [Overview](README.md)

`pgfathom` connects to a PostgreSQL instance, reads the system catalog, cross-references
usage statistics, mines view and function definitions for real joins, and samples data in
a controlled way. From that it builds a semantic model of the database — entities, real
relationships (declared, inferred, evidenced by usage), and findings about the schema's
structural health.

The main output isn't a diagram. It's an actionable report plus ready-to-use artifacts:
suggested DDL, diagnostic queries, and a versioned JSON model other tools can consume.

## Two commands

### `pgfathom audit`

Findings that need **no inference at all** — deterministic, no false-positive risk,
because they're raw catalog facts:

- a declared constraint that's `NOT VALID` and never validated
- a declared FK with no index on the child column (classic `DELETE` trap)
- the coverage block for the analysis

### `pgfathom discover`

The full pipeline — see [02 · Architecture](../02-architecture/README.md) for the 7
stages. Produces a verdict per candidate.

## The five verdicts

| Verdict | Means | Response |
|---|---|---|
| **confirmed** | total containment, no orphans | a forgotten FK — generate the DDL |
| **broken** | real relationship, high but not total containment | **the most valuable finding** — a data bug that's been in production for years. Generates the orphan-listing query before any DDL |
| **weak** | insufficient evidence (single distinct value, mostly nulls, empty table) | doesn't conclude, and says so |
| **rejected** | low containment | name coincidence, discarded — but reported, so an obviously-named column never looks silently ignored |
| **unvalidated** | timeout, permission, unsupported case | never confused with "clean" |

`broken` is the finding that justifies the project: **integrity broken in production, and
nobody knew.**

## Full detail

Real output example, the SQL-artifact conventions, and the full README walkthrough live in
the root [`README.md`](../../../README.md#what-it-does).
24 changes: 24 additions & 0 deletions docs/guide/02-architecture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 02 · Architecture

← [Guide](../README.md)

The "how it works inside": the binary's layers, the core Go types, and the 7-stage
pipeline that turns a `pg_catalog` into verdicts. Read this before touching code.

| Page | Covers |
|---|---|
| [Layers](layers.md) | The 10 `internal/` layers, one-way dependency, tech stack, the bar for a new dependency |
| [Data model](data-model.md) | The core structs — `Schema`, `Candidate`, `Signal`, `Verdict`, `Coverage` |
| [Algorithm](algorithm.md) | The 7 pipeline stages, each with the SQL or rule that implements it |

```mermaid
flowchart TD
L[Layers: where things live] --> M[Data model: the shapes that cross layers]
M --> A[Algorithm: the 7 stages that fill those shapes]
```

## See also

- [03 · Features & Safety](../03-features-and-safety/README.md) — the rules every change
here has to respect
- [`openspec/specs/`](../../../openspec/specs/) — the formal spec per layer, in Portuguese
49 changes: 49 additions & 0 deletions docs/guide/02-architecture/algorithm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Algorithm

← [Guide](../README.md) › [Architecture](README.md)

```
catalog → usage evidence → candidates → scoring → stats prefilter → validation
```

**1. Read the catalog.** Tables, columns, types, keys, indexes, comments, declared FKs —
including `pg_constraint.convalidated`, which is what reveals a `NOT VALID` constraint.
Catalog only, no user data.

**2. Mine usage evidence.** Extracts join predicates from view definitions, function
bodies, and (when available) `pg_stat_statements` — a narrow tokenizing extractor, not a
full SQL parser (a full parser would need `pg_query_go`, which requires cgo). It's allowed
to fail: unrecognized SQL is simply ignored. A missed join is a lost signal, never a wrong
answer — see [Known pitfalls](../03-features-and-safety/known-pitfalls.md).

**3. Generate candidates.** Strips reference affixes from column names
(`_id`, `_key`, `_fk`, …) and matches the result against depluralized table names, using a
**naming profile** — see [Naming profiles](../03-features-and-safety/naming-profiles.md).
A join found in step 2 also creates a candidate directly, bypassing name matching.

**4. Score on metadata alone.** A join found in a view/function is the strongest signal
there is. Exact name match, identical type, single unambiguous target, existing index, and
comment mentions add confidence; ambiguity and generic domain names (`status`, `type`)
subtract it. Below `--min-score`, a candidate is dropped before touching data.

**5. Prefilter on planner statistics.** Reads `pg_stats` and `pg_class.reltuples` — free,
no I/O on tables. If the child column has more distinct values than the parent has rows,
full containment is arithmetically impossible.

**6. Validate against the data.** One aggregate query per surviving candidate — never
fetches rows, only counts, aggregated by distinct value before the anti-join. Containment
is reported in two dimensions (by row and by distinct value), because a single bad value
repeated a million times and a million rare bad values are different problems. Every query
runs under `statement_timeout`; a candidate that times out is marked `unvalidated` and the
run continues.

**7. Verdict.** Total containment on both dimensions → `confirmed`. High but not total →
`broken`, with the orphan counts. Below the rejection threshold → `rejected`. Insufficient
evidence → `weak`, regardless of containment. **In sampled mode, no candidate can ever be
marked `confirmed`** — only `--full` proves the absence of orphans.

## Full detail

The exact SQL for step 6, the type-compatibility rules for step 3, and the statistical
tolerance margins for step 5 are in
[`docs/PGFATHOM.md` § Algoritmo](../../PGFATHOM.md#algoritmo) (Portuguese).
97 changes: 97 additions & 0 deletions docs/guide/02-architecture/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Data model

← [Guide](../README.md) › [Architecture](README.md)

Sketch of the central structures in `internal/model` (pure types, no I/O — see
[Layers](layers.md)). Keeps **read**, **evidenced**, and **inferred** apart at the type
level.

```go
type Table struct {
Schema string
Name string
Columns []Column
PrimaryKey []string
ForeignKeys []ForeignKey // declared only
Stats TableStats
}

type ForeignKey struct {
Name string
Columns []string
RefTable string
RefColumns []string
Validated bool // pg_constraint.convalidated — false = NOT VALID
HasIndex bool
}
```

`Validated` is the field that reveals `audit`'s most treacherous finding — see
[The problem](../01-overview/the-problem.md).

## Candidates and signals

```go
type Signal struct {
Kind SignalKind
Weight float64 // can be negative
Detail string // object name only, NEVER a data value
}

type Candidate struct {
Child ColumnRef
Parent ColumnRef
Signals []Signal
MetaScore float64 // 0..1, before touching data
Validation *Validation // nil if not validated
Verdict Verdict
}
```

Every score must be traceable to the signals that produced it — no scoring path is
allowed to bypass `Signals`. (`openspec/specs/candidate-scoring/spec.md`)

## Validation and verdict

```go
type Validation struct {
Method string // "full" or "sampled"
NotNullRows int64
DistinctVals int64
OrphanRows int64
OrphanVals int64
ContainmentRows float64 // (NotNullRows - OrphanRows) / NotNullRows
ContainmentVals float64 // (DistinctVals - OrphanVals) / DistinctVals
}

type Verdict string

const (
VerdictConfirmed Verdict = "confirmed"
VerdictBroken Verdict = "broken"
VerdictWeak Verdict = "weak"
VerdictRejected Verdict = "rejected"
VerdictUnvalidated Verdict = "unvalidated"
)
```

## Coverage — mandatory on every output

```go
type Coverage struct {
TablesTotal int
TablesAnalyzed int
TablesNoPrivilege []string
CandidatesTimedOut int
StatsResetAt *time.Time
}
```

`Coverage` is the direct materialization of "silence is never reported as a clean bill of
health" — see [Safety guarantees](../03-features-and-safety/safety-guarantees.md).

## Full detail

The complete struct set — `Column`, `ColumnStats` (and why two of its fields are
deliberately unexported), every `SignalKind`, `Finding` — is in
[`docs/PGFATHOM.md` § Modelo interno](../../PGFATHOM.md#modelo-interno) (Portuguese).
65 changes: 65 additions & 0 deletions docs/guide/02-architecture/layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Layers

← [Guide](../README.md) › [Architecture](README.md)

One-way dependency. Every layer testable in isolation.

```
cmd/pgfathom CLI entry, flag parsing, orchestration
|
internal/db connection, pool, security policies, timeouts
|
internal/catalog reads pg_catalog and information_schema
|
internal/sqlprobe extracts join predicates from views and functions
|
internal/model internal model, pure types, no I/O
|
internal/profile naming profiles, loaded from file
|
internal/infer candidate generation and scoring (metadata only)
|
internal/stats planner-statistics prefilter
|
internal/validate validation against data, sampling, anti-join
|
internal/report terminal, JSON, SQL rendering
```

Structural rules:

- **`internal/model` imports nothing from any other layer.** Enforced by an automated
test, not review — it's the easiest dependency to introduce by accident.
- **`internal/infer`, `internal/profile`, `internal/sqlprobe` are deterministic**, no
database access. Testable without Docker or network.
- **`internal/validate` is the only layer that reads user table data.** Everything else in
the pipeline operates on catalog, planner statistics, or in-memory model only.

## Tech stack

| | |
|---|---|
| Language | Go, floor 1.25 |
| Driver | `github.com/jackc/pgx/v5` + `pgxpool` — no ORM |
| CLI | `github.com/spf13/cobra` |
| Config | `github.com/pelletier/go-toml/v2` |
| Concurrency | `golang.org/x/sync/errgroup` with `SetLimit` |
| Log | `log/slog` (stdlib) |
| Terminal table | `text/tabwriter` (stdlib) |
| Interactive guide | `bubbletea` + `lipgloss`, only in `pgfathom setup` |
| Testing | `testing` + `github.com/google/go-cmp` |
| Integration | `testcontainers-go`, behind `//go:build integration` |
| Release | `goreleaser` |
| Lint | `golangci-lint` |

**No cgo.** Cross-compiling has to stay trivial — that's why the SQL-mining layer uses a
narrow extractor of its own instead of `pg_query_go`.

## The dependency tree is a product requirement

> The DBA who has to approve running this against production opens `go.mod` first.

Every new dependency needs a measured cost (modules + binary size) in the PR description —
see [06 · Contributing](../06-contributing/README.md). The full ledger of what's been
accepted and refused, with the numbers, lives in
[`openspec/project.md`](../../../openspec/project.md#stack) (Portuguese).
25 changes: 25 additions & 0 deletions docs/guide/03-features-and-safety/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 03 · Features & Safety

← [Guide](../README.md)

What makes it safe to point at a production database, and the two most delicate gears in
the product: recognizing naming convention in any language, and knowing where its own
confidence runs out.

| Page | Covers |
|---|---|
| [Naming profiles](naming-profiles.md) | How pt-br/en/es work, why depluralization returns a *set* of forms, why a new profile is the easiest contribution in the project |
| [Safety guarantees](safety-guarantees.md) | The 5 non-negotiable rules — and, more importantly, how each is enforced by a test, not a promise |
| [Known pitfalls](known-pitfalls.md) | Specific ways the tool can be wrong with confidence: sampling against clustered orphans, usage counters that reset silently, silent partial coverage |

## Why these three live together

All three answer "what can go wrong, and what the project does about it" — one about
input (naming the parser doesn't recognize), one about policy (a rule that never bends),
one about interpretation (right number, wrong conclusion). Anyone reviewing a PR in
[02 · Architecture](../02-architecture/README.md) should know all three before approving.

## See also

[06 · Contributing](../06-contributing/README.md) — these are the same rules a PR gets
evaluated against.
Loading
Loading