Skip to content

Commit 91b873c

Browse files
committed
Add PostgresSchema to manage Postgres schemas with
1 parent 6eff3f1 commit 91b873c

File tree

9 files changed

+700
-3
lines changed

9 files changed

+700
-3
lines changed

docs/source/api_reference.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,17 @@ API Reference
3434
.. automodule:: psqlextra.indexes
3535

3636
.. autoclass:: UniqueIndex
37+
3738
.. autoclass:: ConditionalUniqueIndex
39+
3840
.. autoclass:: CaseInsensitiveUniqueIndex
3941

4042
.. automodule:: psqlextra.locking
4143
:members:
4244

45+
.. automodule:: psqlextra.schema
46+
:members:
47+
4348
.. automodule:: psqlextra.partitioning
4449
:members:
4550

docs/source/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ Explore the documentation to learn about all features:
4040
Support for explicit table-level locks.
4141

4242

43+
* :ref:`Creating/dropping schemas <schemas_page>`
44+
45+
Support for managing Postgres schemas.
46+
47+
4348
.. toctree::
4449
:maxdepth: 2
4550
:caption: Overview
@@ -54,6 +59,7 @@ Explore the documentation to learn about all features:
5459
expressions
5560
annotations
5661
locking
62+
schemas
5763
settings
5864
api_reference
5965
major_releases

docs/source/schemas.rst

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
.. include:: ./snippets/postgres_doc_links.rst
2+
3+
.. _schemas_page:
4+
5+
Schema
6+
======
7+
8+
The :meth:`~psqlextra.schema.PostgresSchema` class provides basic schema management functionality.
9+
10+
Django does **NOT** support custom schemas. This module does not attempt to solve that problem.
11+
12+
This module merely allows you to create/drop schemas and allow you to execute raw SQL in a schema. It is not attempt at bringing multi-schema support to Django.
13+
14+
15+
Reference an existing schema
16+
----------------------------
17+
18+
.. code-block:: python
19+
20+
for psqlextra.schema import PostgresSchema
21+
22+
schema = PostgresSchema("myschema")
23+
24+
with schema.connection.cursor() as cursor:
25+
cursor.execute("SELECT * FROM tablethatexistsinmyschema")
26+
27+
28+
Checking if a schema exists
29+
---------------------------
30+
31+
.. code-block:: python
32+
33+
for psqlextra.schema import PostgresSchema
34+
35+
schema = PostgresSchema("myschema")
36+
if PostgresSchema.exists("myschema"):
37+
print("exists!")
38+
else:
39+
print('does not exist!")
40+
41+
42+
Creating a new schema
43+
---------------------
44+
45+
With a custom name
46+
******************
47+
48+
.. code-block:: python
49+
50+
for psqlextra.schema import PostgresSchema
51+
52+
# will raise an error if the schema already exists
53+
schema = PostgresSchema.create("myschema")
54+
55+
56+
Re-create if necessary with a custom name
57+
*****************************************
58+
59+
.. warning::
60+
61+
If the schema already exists and it is non-empty or something is referencing it, it will **NOT** be dropped. Specify ``cascade=True`` to drop all of the schema's contents and **anything referencing it**.
62+
63+
.. code-block:: python
64+
65+
for psqlextra.schema import PostgresSchema
66+
67+
# will drop existing schema named `myschema` if it
68+
# exists and re-create it
69+
schema = PostgresSchema.drop_and_create("myschema")
70+
71+
# will drop the schema and cascade it to its contents
72+
# and anything referencing the schema
73+
schema = PostgresSchema.drop_and_create("otherschema", cascade=True)
74+
75+
76+
With a random name
77+
******************
78+
79+
.. code-block:: python
80+
81+
for psqlextra.schema import PostgresSchema
82+
83+
# schema name will be "myprefix_<timestamp>"
84+
schema = PostgresSchema.create_random("myprefix")
85+
print(schema.name)
86+
87+
88+
Temporary schema with random name
89+
*********************************
90+
91+
Use the :meth:`~psqlextra.schema.postgres_temporary_schema` context manager to create a schema with a random name. The schema will only exist within the context manager.
92+
93+
By default, the schema is not dropped if an exception occurs in the context manager. This prevents unexpected data loss. Specify ``drop_on_throw=True`` to drop the schema if an exception occurs.
94+
95+
Without an outer transaction, the temporary schema might not be dropped when your program is exits unexpectedly (for example; if it is killed with SIGKILL). Wrap the creation of the schema in a transaction to make sure the schema is cleaned up when an error occurs or your program exits suddenly.
96+
97+
.. warning::
98+
99+
By default, the drop will fail if the schema is not empty or there is anything referencing the schema. Specify ``cascade=True`` to drop all of the schema's contents and **anything referencing it**.
100+
101+
.. note::
102+
103+
104+
.. code-block:: python
105+
106+
for psqlextra.schema import postgres_temporary_schema
107+
108+
with postgres_temporary_schema("myprefix") as schema:
109+
pass
110+
111+
with postgres_temporary_schema("otherprefix", drop_on_throw=True) as schema:
112+
raise ValueError("drop it like it's hot")
113+
114+
with postgres_temporary_schema("greatprefix", cascade=True) as schema:
115+
with schema.connection.cursor() as cursor:
116+
cursor.execute(f"CREATE TABLE {schema.name} AS SELECT 'hello'")
117+
118+
with postgres_temporary_schema("amazingprefix", drop_on_throw=True, cascade=True) as schema:
119+
with schema.connection.cursor() as cursor:
120+
cursor.execute(f"CREATE TABLE {schema.name} AS SELECT 'hello'")
121+
122+
raise ValueError("oops")
123+
124+
Deleting a schema
125+
-----------------
126+
127+
Any schema can be dropped, including ones not created by :class:`~psqlextra.schema.PostgresSchema`.
128+
129+
The ``public`` schema cannot be dropped. This is a Postgres built-in and it is almost always a mistake to drop it. A :class:`~django.core.exceptions.SuspiciousOperation` erorr will be raised if you attempt to drop the ``public`` schema.
130+
131+
.. warning::
132+
133+
By default, the drop will fail if the schema is not empty or there is anything referencing the schema. Specify ``cascade=True`` to drop all of the schema's contents and **anything referencing it**.
134+
135+
.. code-block:: python
136+
137+
for psqlextra.schema import PostgresSchema
138+
139+
schema = PostgresSchema.drop("myprefix")
140+
schema = PostgresSchema.drop("myprefix", cascade=True)
141+
142+
143+
Executing queries within a schema
144+
---------------------------------
145+
146+
By default, a connection operates in the ``public`` schema. The schema offers a connection scoped to that schema that sets the Postgres ``search_path`` to only search within that schema.
147+
148+
.. warning::
149+
150+
This can be abused to manage Django models in a custom schema. This is not a supported workflow and there might be unexpected issues from attempting to do so.
151+
152+
.. warning::
153+
154+
Do not pass the connection to a different thread. It is **NOT** thread safe.
155+
156+
.. code-block:: python
157+
158+
from psqlextra.schema import PostgresSchema
159+
160+
schema = PostgresSchema.create("myschema")
161+
162+
with schema.connection.cursor() as cursor:
163+
# table gets created within the `myschema` schema, without
164+
# explicitly specifying the schema name
165+
cursor.execute("CREATE TABLE mytable AS SELECT 'hello'")
166+
167+
with schema.connection.schema_editor() as schema_editor:
168+
# creates a table for the model within the schema
169+
schema_editor.create_model(MyModel)

psqlextra/backend/introspection.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ def get_partitioned_tables(
6969
) -> PostgresIntrospectedPartitonedTable:
7070
"""Gets a list of partitioned tables."""
7171

72-
sql = """
72+
cursor.execute(
73+
"""
7374
SELECT
7475
pg_class.relname,
7576
pg_partitioned_table.partstrat
@@ -80,8 +81,7 @@ def get_partitioned_tables(
8081
ON
8182
pg_class.oid = pg_partitioned_table.partrelid
8283
"""
83-
84-
cursor.execute(sql)
84+
)
8585

8686
return [
8787
PostgresIntrospectedPartitonedTable(
@@ -191,6 +191,19 @@ def get_partition_key(self, cursor, table_name: str) -> List[str]:
191191
def get_columns(self, cursor, table_name: str):
192192
return self.get_table_description(cursor, table_name)
193193

194+
def get_schema_list(self, cursor) -> List[str]:
195+
"""A flat list of available schemas."""
196+
197+
sql = """
198+
SELECT
199+
schema_name
200+
FROM
201+
information_schema.schemata
202+
"""
203+
204+
cursor.execute(sql, tuple())
205+
return [name for name, in cursor.fetchall()]
206+
194207
def get_constraints(self, cursor, table_name: str):
195208
"""Retrieve any constraints or keys (unique, pk, fk, check, index)
196209
across one or more columns.

psqlextra/backend/schema.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class PostgresSchemaEditor(SchemaEditor):
4545
sql_reset_table_storage_setting = "ALTER TABLE %s RESET (%s)"
4646

4747
sql_alter_table_schema = "ALTER TABLE %s SET SCHEMA %s"
48+
sql_create_schema = "CREATE SCHEMA %s"
49+
sql_delete_schema = "DROP SCHEMA %s"
50+
sql_delete_schema_cascade = "DROP SCHEMA %s CASCADE"
4851

4952
sql_create_view = "CREATE VIEW %s AS (%s)"
5053
sql_replace_view = "CREATE OR REPLACE VIEW %s AS (%s)"
@@ -84,6 +87,21 @@ def __init__(self, connection, collect_sql=False, atomic=True):
8487
self.deferred_sql = []
8588
self.introspection = PostgresIntrospection(self.connection)
8689

90+
def create_schema(self, name: str) -> None:
91+
"""Creates a Postgres schema."""
92+
93+
self.execute(self.sql_create_schema % self.quote_name(name))
94+
95+
def delete_schema(self, name: str, cascade: bool) -> None:
96+
"""Drops a Postgres schema."""
97+
98+
sql = (
99+
self.sql_delete_schema
100+
if not cascade
101+
else self.sql_delete_schema_cascade
102+
)
103+
self.execute(sql % self.quote_name(name))
104+
87105
def create_model(self, model: Type[Model]) -> None:
88106
"""Creates a new model."""
89107

psqlextra/error.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Optional
2+
3+
import psycopg2
4+
5+
from django import db
6+
7+
8+
def extract_postgres_error(error: db.Error) -> Optional[psycopg2.Error]:
9+
"""Extracts the underlying :see:psycopg2.Error from the specified Django
10+
database error.
11+
12+
As per PEP-249, Django wraps all database errors in its own
13+
exception. We can extract the underlying database error by examaning
14+
the cause of the error.
15+
"""
16+
17+
if not isinstance(error.__cause__, psycopg2.Error):
18+
return None
19+
20+
return error.__cause__

0 commit comments

Comments
 (0)