v0.154.0
  1"""Cluster-level database management — CREATE/DROP DATABASE and friends.
  2
  3Everything else in `plain.postgres` operates *inside* the database that
  4`POSTGRES_URL` points at. This module is the exception: it operates on the
  5cluster, which means it needs a role with `CREATEDB` and a connection to the
  6`postgres` maintenance database.
  7
  8That makes it a **development and test** capability, not a production one —
  9most managed Postgres providers hand your app a role that owns exactly one
 10database and cannot create more. Nothing on the runtime path imports this
 11module, and `tests/internal/test_databases_not_on_runtime_path.py` enforces
 12that.
 13
 14This module provides mechanism only. Policy — whether to prompt before
 15clobbering, how to name databases, what to store in a comment, when to fork
 16via `TEMPLATE` versus `pg_dump` — belongs to the caller.
 17"""
 18
 19from __future__ import annotations
 20
 21from collections.abc import Generator
 22from contextlib import contextmanager
 23from dataclasses import dataclass
 24
 25import psycopg
 26from psycopg import sql
 27
 28from plain.postgres.database_url import DatabaseConfig
 29from plain.postgres.sources import build_connection_params
 30
 31__all__ = [
 32    "DatabaseInfo",
 33    "connection_count",
 34    "create_database",
 35    "database_exists",
 36    "drop_database",
 37    "get_database_comment",
 38    "list_databases",
 39    "maintenance_cursor",
 40    "set_database_comment",
 41    "terminate_connections",
 42]
 43
 44
 45@dataclass(frozen=True)
 46class DatabaseInfo:
 47    """A row from `list_databases()`."""
 48
 49    name: str
 50    comment: str | None
 51    size_bytes: int
 52
 53
 54@contextmanager
 55def maintenance_cursor(config: DatabaseConfig) -> Generator[psycopg.Cursor]:
 56    """Yield a cursor on the `postgres` maintenance database.
 57
 58    CREATE DATABASE / DROP DATABASE can't run against the target database
 59    itself and can't run inside a transaction — so connect to `postgres`
 60    with autocommit and use a raw psycopg cursor (no wrapper machinery,
 61    no pool).
 62    """
 63    maintenance_config: DatabaseConfig = {**config, "DATABASE": "postgres"}
 64    params = build_connection_params(maintenance_config)
 65    with psycopg.connect(**params, autocommit=True) as conn:
 66        with conn.cursor() as cursor:
 67            yield cursor
 68
 69
 70def create_database(
 71    config: DatabaseConfig, *, name: str, template: str | None = None
 72) -> None:
 73    """CREATE DATABASE, optionally copying an existing one via TEMPLATE.
 74
 75    `TEMPLATE` is a file-level copy, so it's near-instant regardless of data
 76    size — but Postgres requires the template database to have no other
 77    connections. Call `terminate_connections()` first, or fall back to
 78    dump/restore, if the source may be in use.
 79
 80    Raises `psycopg.errors.DuplicateDatabase` if `name` already exists.
 81    """
 82    if template:
 83        statement = sql.SQL("CREATE DATABASE {} TEMPLATE {}").format(
 84            sql.Identifier(name), sql.Identifier(template)
 85        )
 86    else:
 87        statement = sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))
 88
 89    with maintenance_cursor(config) as cursor:
 90        cursor.execute(statement)
 91
 92
 93def drop_database(config: DatabaseConfig, *, name: str, force: bool = False) -> None:
 94    """DROP DATABASE IF EXISTS.
 95
 96    `force` adds `WITH (FORCE)`, which terminates other connections to the
 97    database instead of failing.
 98    """
 99    if force:
100        statement = sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
101            sql.Identifier(name)
102        )
103    else:
104        statement = sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(name))
105
106    with maintenance_cursor(config) as cursor:
107        cursor.execute(statement)
108
109
110def database_exists(config: DatabaseConfig, *, name: str) -> bool:
111    with maintenance_cursor(config) as cursor:
112        cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", [name])
113        return cursor.fetchone() is not None
114
115
116def list_databases(config: DatabaseConfig) -> list[DatabaseInfo]:
117    """List every user database on the cluster, ordered by name.
118
119    Postgres' own `postgres`, `template0` and `template1` are always excluded —
120    they're cluster furniture, never something a caller is managing. Any finer
121    selection is the caller's job: name-pattern filtering in SQL can't express
122    the ownership rules callers actually have (see how plain-dev matches on
123    metadata as well as names).
124    """
125    with maintenance_cursor(config) as cursor:
126        cursor.execute(
127            "SELECT datname, shobj_description(oid, 'pg_database'), "
128            "pg_database_size(datname) "
129            "FROM pg_database "
130            "WHERE NOT datistemplate AND datname <> 'postgres' "
131            "ORDER BY datname"
132        )
133        rows = cursor.fetchall()
134
135    return [
136        DatabaseInfo(name=name, comment=comment, size_bytes=size)
137        for name, comment, size in rows
138    ]
139
140
141def get_database_comment(config: DatabaseConfig, *, name: str) -> str | None:
142    """Return the database's COMMENT as raw text, or None if unset.
143
144    Callers that store structured data are responsible for their own encoding.
145    """
146    with maintenance_cursor(config) as cursor:
147        cursor.execute(
148            "SELECT shobj_description(oid, 'pg_database') "
149            "FROM pg_database WHERE datname = %s",
150            [name],
151        )
152        row = cursor.fetchone()
153
154    return row[0] if row else None
155
156
157def set_database_comment(config: DatabaseConfig, *, name: str, comment: str) -> None:
158    """Set the database's COMMENT to raw text."""
159    with maintenance_cursor(config) as cursor:
160        cursor.execute(
161            sql.SQL("COMMENT ON DATABASE {} IS {}").format(
162                sql.Identifier(name), sql.Literal(comment)
163            )
164        )
165
166
167def connection_count(config: DatabaseConfig, *, name: str) -> int:
168    """Count backends currently connected to `name`, excluding our own."""
169    with maintenance_cursor(config) as cursor:
170        cursor.execute(
171            "SELECT count(*) FROM pg_stat_activity "
172            "WHERE datname = %s AND pid <> pg_backend_pid()",
173            [name],
174        )
175        row = cursor.fetchone()
176        return row[0] if row else 0
177
178
179def terminate_connections(config: DatabaseConfig, *, name: str) -> None:
180    """Terminate every other backend connected to `name`.
181
182    Needed before `CREATE DATABASE … TEMPLATE`, which requires the source to
183    be idle.
184    """
185    with maintenance_cursor(config) as cursor:
186        cursor.execute(
187            "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
188            "WHERE datname = %s AND pid <> pg_backend_pid()",
189            [name],
190        )