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 .claude/rules/plain-postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ Run `uv run plain docs postgres` for full patterns with code examples.

Run `uv run plain docs postgres` for full patterns with code examples.

## Reading the Data Model

Before reasoning about how models relate — what cascades, what is one-to-one, what a reverse accessor is called — run `uv run plain postgres models` (a model name or package to scope it, `--json` for data). It needs no database. Don't infer cardinality from field definitions: uniqueness lives in constraints, and a partial unique is not a one-to-one.

## Database Doctor

Use the `/plain-postgres-doctor` skill to check overall database health — migration sync, schema correctness, and operational health.
Expand Down
51 changes: 50 additions & 1 deletion plain-postgres/plain/postgres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [Fields](#fields)
- [Relationships](#relationships)
- [Constraints](#constraints)
- [Describing the data model](#describing-the-data-model)
- [Forms](#forms)
- [Architecture](#architecture)
- [Diagnostics](#diagnostics)
Expand Down Expand Up @@ -1293,6 +1294,7 @@ In development (`DEBUG=True`), sync auto-generates migrations before applying th
| `plain postgres sync --check` | Exit non-zero if anything would change (for CI) |
| `plain postgres schema` | Show schema state with drift detection |
| `plain postgres schema --json` | Machine-readable schema output |
| `plain postgres models` | Describe the models: relations, constraints, reverse accessors |
| `plain postgres converge` | Run convergence alone (advanced) |

### Structural migrations
Expand Down Expand Up @@ -1477,14 +1479,15 @@ When you run `postgres sync`, convergence detects that these indexes and constra
- **Per-operation commits** — each fix is committed independently so a single failure doesn't roll back other fixes.
- **Self-healing** — detects and rebuilds `INVALID` indexes (e.g. from a previously failed `CREATE INDEX CONCURRENTLY`).
- **Rename-aware** — detects renamed indexes and constraints by matching their structure, avoiding unnecessary drop + recreate.
- **Repairs doubled `%` literals** — a constraint created by an earlier plain-postgres stored `'100%'` as `'100%%'`. Convergence recognizes that one difference and replaces the constraint under its own name instead of treating it as a definition change you have to stage.

**Inspecting schema state:**

Use `postgres schema` to see what convergence would do. It shows every model's columns, indexes, and constraints compared against the database, with issues highlighted:

```bash
plain postgres schema # all models
plain postgres schema User # single model
plain postgres schema User # one model, or a package
plain postgres schema --json # machine-readable output
```

Expand Down Expand Up @@ -2123,6 +2126,52 @@ nickname: Field[str] = types.TextField(max_length=50, allow_null=True)
nickname: Field[str] = types.TextField(max_length=50, default="")
```

## Describing the data model

`plain postgres models` prints the data model as the registry sees it: every field, every relation with its target and `on_delete` rule, the relations pointing back at each model, and the constraints and indexes with their conditions compiled to SQL. It reads the model classes, so it needs no database.

```bash
plain postgres models # every model in the app
plain postgres models Thread # one model, by class name
plain postgres models changes.Thread # or by label, or by table name
plain postgres models changes # a whole package
plain postgres models --all # include models from installed packages
plain postgres models --json # the same facts, structured
```

```
changes.Thread → changes_thread
id PrimaryKey PK
key Text unique
bot → projects.Bot CASCADE
owner → users.User RESTRICT
origin → changes.Thread SET_NULL null
← terminals.Terminal.change filter: terminal
← changes.Computer.thread 1:1 filter: one_offs
unique change_key_unique (key)
unique change_one_triggered_open_per_bot (bot) WHERE ("closed_at" IS NULL AND NOT ("trigger" = ''))
index change_bot_idx (bot)
```

The point is the facts you can't read off a `models.py` by eye:

- **`unique` and `1:1` mean effective uniqueness.** A field is marked unique when an unconditional, single-field unique constraint covers it, and a foreign key covered that way is one-to-one. A partial unique (one with a `condition`) is listed with its `WHERE` clause and never promoted — at most one open thread per bot is not the same thing as one thread per bot.
- **Reverse relations are listed on the model they point at**, with the declared [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) or [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany) accessor when there is one, and the name to use in `filter()` either way.
- **Check constraints, partial conditions and expression indexes are shown as the SQL Postgres enforces**, not as `Q` objects. One wrinkle: with no database connection open, a string literal that contains a backslash is written in Postgres's `E'...'` escape form, which means the same thing.
- **A relation whose target never resolved** (a typo in a string reference, a package no longer installed) shows the reference as written instead of failing the whole listing.

`--json` carries everything above as data; its shape is the `to_dict()` of each [`ModelDescription`](./describe.py#ModelDescription). It is the input for anything built on top: an entity-relationship diagram, a containment outline, a what-deletes-what walk. Those are views, and they are left to whoever is reading, so the command stays a statement of fact.

The same facts are available in Python through [`describe_models`](./describe.py#describe_models):

```python
from plain.postgres.describe import describe_models

for model in describe_models(("changes",)):
for relation in model.relations:
print(model.label, relation.name, relation.to, relation.on_delete)
```

## Forms

Models integrate with [plain.forms](../../../plain-forms/plain/forms/README.md):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ Run `uv run plain docs postgres` for full patterns with code examples.

Run `uv run plain docs postgres` for full patterns with code examples.

## Reading the Data Model

Before reasoning about how models relate — what cascades, what is one-to-one, what a reverse accessor is called — run `uv run plain postgres models` (a model name or package to scope it, `--json` for data). It needs no database. Don't infer cardinality from field definitions: uniqueness lives in constraints, and a partial unique is not a one-to-one.

## Database Doctor

Use the `/plain-postgres-doctor` skill to check overall database health — migration sync, schema correctness, and operational health.
Expand Down
2 changes: 2 additions & 0 deletions plain-postgres/plain/postgres/cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .converge import converge
from .decorators import cli_schema_lock, database_management_command
from .diagnose import diagnose
from .models import models
from .schema import schema
from .sync import sync

Expand All @@ -27,6 +28,7 @@ def cli() -> None:

cli.add_command(converge)
cli.add_command(diagnose)
cli.add_command(models)
cli.add_command(schema)
cli.add_command(sync)

Expand Down
136 changes: 136 additions & 0 deletions plain-postgres/plain/postgres/cli/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import json

import click

from ..describe import ModelDescription, describe_models


@click.command()
@click.argument("targets", nargs=-1)
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@click.option(
"--all",
"include_packages",
is_flag=True,
help="Include models from installed packages, not just the app's",
)
def models(
targets: tuple[str, ...],
output_json: bool,
include_packages: bool,
) -> None:
"""Describe the data model: fields, relations, constraints.

TARGETS narrow the output to models (`Thread`, `changes.Thread`) or whole
packages (`changes`). Read from the model registry, so no database is needed.
"""
if include_packages and targets:
raise click.UsageError(
"--all widens the default selection; with targets it has nothing to do."
)

try:
descriptions = describe_models(targets, include_packages=include_packages)
except LookupError as e:
raise click.ClickException(str(e)) from e

if output_json:
click.echo(
json.dumps({"models": [d.to_dict() for d in descriptions]}, indent=2)
)
else:
for i, description in enumerate(descriptions):
if i > 0:
click.echo()
_render_model(description)


def _render_model(description: ModelDescription) -> None:
click.secho(description.label, bold=True, nl=False)
click.secho(f" → {description.table}", dim=True)

for model_field in description.fields:
type_name = model_field.type.removesuffix("Field")
marks = []
if model_field.primary_key:
marks.append(click.style("PK", fg="yellow"))
if model_field.unique:
marks.append(click.style("unique", fg="yellow"))
if model_field.null:
marks.append(click.style("null", dim=True))
click.echo(
f" {model_field.name:24s} {_pad(styled=click.style(type_name, fg='cyan'), visible_length=len(type_name))} {' '.join(marks)}".rstrip()
)

for relation in description.relations:
if relation.type == "ManyToManyField":
target = f"⇆ {relation.to}"
marks = [click.style(f"through {relation.through}", dim=True)]
else:
target = f"→ {relation.to}"
marks = [_style_on_delete(relation.on_delete)]
if relation.unique:
marks.append(click.style("1:1", fg="yellow"))
if relation.null:
marks.append(click.style("null", dim=True))
click.echo(
f" {relation.name:24s} {_pad(styled=click.style(target, fg='cyan'), visible_length=len(target))} {' '.join(marks)}"
)

for reverse in description.reverse:
source = f"{reverse.from_model}.{reverse.field}"
marks = []
if reverse.accessor:
marks.append(click.style(f".{reverse.accessor}", fg="cyan"))
if reverse.one_to_one:
marks.append(click.style("1:1", fg="yellow"))
if reverse.type == "ManyToManyField":
marks.append(click.style("many-to-many", dim=True))
marks.append(click.style(f"filter: {reverse.query_name}", dim=True))
click.echo(f" {click.style('←', dim=True)} {source:48s} {' '.join(marks)}")

for constraint in description.constraints:
if constraint.kind == "unique":
detail = _columns_detail(
columns=constraint.fields or constraint.expressions,
condition=constraint.condition,
)
else:
detail = constraint.check or ""
_echo_schema_line(kind=constraint.kind, name=constraint.name, detail=detail)

for index in description.indexes:
detail = _columns_detail(
columns=index.fields or index.expressions, condition=index.condition
)
_echo_schema_line(kind="index", name=index.name, detail=detail)


def _columns_detail(*, columns: list[str], condition: str | None) -> str:
"""`(a, b)`, plus ` WHERE ...` for a partial constraint or index.

Expressions arrive parenthesized the way their DDL writes them, so an
expression index reads `((LOWER("name")))`, as in `CREATE INDEX`."""
detail = f"({', '.join(columns)})"
if condition:
detail += f" WHERE {condition}"
return detail


def _pad(*, styled: str, visible_length: int, width: int = 22) -> str:
"""Pad after the styled text, so the ANSI codes don't count toward the width."""
return styled + " " * max(width - visible_length, 0)


def _echo_schema_line(*, kind: str, name: str, detail: str) -> None:
click.echo(
f" {click.style(f'{kind:8s}', dim=True)} {name} {click.style(detail, dim=True)}"
)


def _style_on_delete(on_delete: str | None) -> str:
if on_delete == "CASCADE":
return click.style(on_delete, fg="red")
if on_delete == "SET_NULL":
return click.style(on_delete, fg="yellow")
return click.style(on_delete or "", fg="blue")
32 changes: 14 additions & 18 deletions plain-postgres/plain/postgres/cli/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from ..convergence.analysis import ModelAnalysis, analyze_model
from ..convergence.planning import can_auto_correct
from ..db import get_connection
from ..describe import select_models
from ..introspection import MANAGED_CONSTRAINT_TYPES, get_unknown_tables
from ..registry import models_registry
from .decorators import database_management_command


Expand Down Expand Up @@ -105,24 +105,20 @@ def _render_model(analysis: ModelAnalysis) -> None:


@click.command()
@click.argument("model_label", required=False)
@click.argument("target", required=False)
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@database_management_command
def schema(model_label: str | None, output_json: bool) -> None:
"""Show database schema from models, compared against the actual database"""
models = models_registry.get_models()

if model_label:
model_label_lower = model_label.lower()
models = [
m
for m in models
if m.model_options.label_lower == model_label_lower
or m.model_options.db_table == model_label
or m.__name__.lower() == model_label_lower
]
if not models:
raise click.ClickException(f"No model found matching '{model_label}'")
def schema(target: str | None, output_json: bool) -> None:
"""Show database schema from models, compared against the actual database.

TARGET narrows it to one model (by class name, label or table) or one
package.
"""
targets = (target,) if target else ()
try:
models = select_models(targets, include_packages=True)
except LookupError as e:
raise click.ClickException(str(e)) from e

conn = get_connection()

Expand All @@ -132,7 +128,7 @@ def schema(model_label: str | None, output_json: bool) -> None:
for model in models:
analyses.append(analyze_model(conn, cursor, model))

unknown_tables = get_unknown_tables(conn) if not model_label else []
unknown_tables = get_unknown_tables(conn) if not target else []
total_issues = sum(a.issue_count for a in analyses) + len(unknown_tables)

if output_json:
Expand Down
Loading
Loading