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 )