Skip to content

Commit 0094c55

Browse files
committed
Remove connections scoped to schema
It's too tricky to get this right. It'll lead to surprises because: 1. It breaks with transaction pooling. 2. Interaction with `SET LOCAL` is strange. A `SET` command after a `SET LOCAL` overrides it. I already shot myself in the foot twice since implementing this.
1 parent a87eb15 commit 0094c55

File tree

3 files changed

+11
-169
lines changed

3 files changed

+11
-169
lines changed

docs/source/schemas.rst

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -153,36 +153,3 @@ The ``public`` schema cannot be dropped. This is a Postgres built-in and it is a
153153
154154
schema = PostgresSchema.drop("myprefix")
155155
schema = PostgresSchema.drop("myprefix", cascade=True)
156-
157-
158-
Executing queries within a schema
159-
---------------------------------
160-
161-
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.
162-
163-
.. warning::
164-
165-
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.
166-
167-
.. warning::
168-
169-
Do not use this in the following scenarios:
170-
171-
1. You access the connection from multiple threads. Scoped connections are **NOT** thread safe.
172-
173-
2. The underlying database connection is passed through a connection pooler in transaction pooling mode.
174-
175-
.. code-block:: python
176-
177-
from psqlextra.schema import PostgresSchema
178-
179-
schema = PostgresSchema.create("myschema")
180-
181-
with schema.connection.cursor() as cursor:
182-
# table gets created within the `myschema` schema, without
183-
# explicitly specifying the schema name
184-
cursor.execute("CREATE TABLE mytable AS SELECT 'hello'")
185-
186-
with schema.connection.schema_editor() as schema_editor:
187-
# creates a table for the model within the schema
188-
schema_editor.create_model(MyModel)

psqlextra/schema.py

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,11 @@
22

33
from contextlib import contextmanager
44

5-
import wrapt
6-
75
from django.core.exceptions import SuspiciousOperation, ValidationError
86
from django.db import DEFAULT_DB_ALIAS, connections, transaction
9-
from django.db.backends.base.base import BaseDatabaseWrapper
10-
from django.db.backends.utils import CursorWrapper
117
from django.utils import timezone
128

139

14-
class PostgresSchemaConnectionWrapper(wrapt.ObjectProxy):
15-
"""Wraps a Django database connection and ensures that each cursor operates
16-
within the specified schema."""
17-
18-
def __init__(self, connection, schema) -> None:
19-
super().__init__(connection)
20-
21-
self._self_schema = schema
22-
23-
@contextmanager
24-
def schema_editor(self):
25-
with self.__wrapped__.schema_editor() as schema_editor:
26-
schema_editor.connection = self
27-
yield schema_editor
28-
29-
@contextmanager
30-
def cursor(self) -> CursorWrapper:
31-
schema = self._self_schema
32-
33-
with self.__wrapped__.cursor() as cursor:
34-
quoted_name = self.ops.quote_name(schema.name)
35-
cursor.execute(f"SET search_path = {quoted_name}")
36-
try:
37-
yield cursor
38-
finally:
39-
cursor.execute("SET search_path TO DEFAULT")
40-
41-
4210
class PostgresSchema:
4311
"""Represents a Postgres schema.
4412
@@ -191,20 +159,6 @@ def delete(self, *, cascade: bool = False) -> None:
191159
with connections[self.using].schema_editor() as schema_editor:
192160
schema_editor.delete_schema(self.name, cascade=cascade)
193161

194-
@property
195-
def connection(self) -> BaseDatabaseWrapper:
196-
"""Obtains a database connection scoped to this schema.
197-
198-
Do not use this in the following scenarios:
199-
200-
1. You access the connection from multiple threads. Scoped
201-
connections are NOT thread safe.
202-
2. The underlying database connection is passed through a
203-
connection pooler in transaction pooling mode.
204-
"""
205-
206-
return PostgresSchemaConnectionWrapper(connections[self.using], self)
207-
208162
@classmethod
209163
def _verify_generated_name_length(cls, prefix: str, suffix: str) -> None:
210164
max_prefix_length = cls.NAME_MAX_LENGTH - len(suffix)

tests/test_schema.py

Lines changed: 11 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import uuid
21

32
import freezegun
43
import pytest
@@ -136,7 +135,7 @@ def test_postgres_schema_delete_not_empty():
136135
schema = PostgresSchema.create("test")
137136
assert _does_schema_exist(schema.name)
138137

139-
with schema.connection.cursor() as cursor:
138+
with connection.cursor() as cursor:
140139
cursor.execute("CREATE TABLE test.bla AS SELECT 'hello'")
141140

142141
with pytest.raises(InternalError) as exc_info:
@@ -150,96 +149,14 @@ def test_postgres_schema_delete_cascade_not_empty():
150149
schema = PostgresSchema.create("test")
151150
assert _does_schema_exist(schema.name)
152151

153-
with schema.connection.cursor() as cursor:
152+
with connection.cursor() as cursor:
154153
cursor.execute("CREATE TABLE test.bla AS SELECT 'hello'")
155154

156155
schema.delete(cascade=True)
157156
assert not _does_schema_exist(schema.name)
158157

159158

160-
def test_postgres_schema_connection():
161-
schema = PostgresSchema.create("test")
162-
163-
with schema.connection.cursor() as cursor:
164-
# Creating a table without specifying the schema should create
165-
# it in our schema and we should be able to select from it without
166-
# specifying the schema.
167-
cursor.execute("CREATE TABLE myschematable AS SELECT 'myschema'")
168-
cursor.execute("SELECT * FROM myschematable")
169-
assert cursor.fetchone() == ("myschema",)
170-
171-
# Proof that the table was created in our schema even though we
172-
# never explicitly told it to do so.
173-
cursor.execute(
174-
"SELECT table_schema FROM information_schema.tables WHERE table_name = %s",
175-
("myschematable",),
176-
)
177-
assert cursor.fetchone() == (schema.name,)
178-
179-
# Creating a table in another schema, we should not be able
180-
# to select it without specifying the schema since our
181-
# schema scoped connection only looks at our schema by default.
182-
cursor.execute(
183-
"CREATE TABLE public.otherschematable AS SELECT 'otherschema'"
184-
)
185-
with pytest.raises(ProgrammingError) as exc_info:
186-
cursor.execute("SELECT * FROM otherschematable")
187-
188-
cursor.execute("ROLLBACK")
189-
190-
pg_error = extract_postgres_error(exc_info.value)
191-
assert pg_error.pgcode == errorcodes.UNDEFINED_TABLE
192-
193-
194-
def test_postgres_schema_connection_does_not_affect_default():
195-
schema = PostgresSchema.create("test")
196-
197-
with schema.connection.cursor() as cursor:
198-
cursor.execute("SHOW search_path")
199-
assert cursor.fetchone() == ("test",)
200-
201-
with connection.cursor() as cursor:
202-
cursor.execute("SHOW search_path")
203-
assert cursor.fetchone() == ('"$user", public',)
204-
205-
206-
@pytest.mark.django_db(transaction=True)
207-
def test_postgres_schema_connection_does_not_affect_default_after_throw():
208-
schema = PostgresSchema.create(str(uuid.uuid4()))
209-
210-
with pytest.raises(ProgrammingError):
211-
with schema.connection.cursor() as cursor:
212-
cursor.execute("COMMIT")
213-
cursor.execute("SELECT frombadtable")
214-
215-
with connection.cursor() as cursor:
216-
cursor.execute("ROLLBACK")
217-
cursor.execute("SHOW search_path")
218-
assert cursor.fetchone() == ('"$user", public',)
219-
220-
221-
def test_postgres_schema_connection_schema_editor():
222-
schema = PostgresSchema.create("test")
223-
224-
with schema.connection.schema_editor() as schema_editor:
225-
with schema_editor.connection.cursor() as cursor:
226-
cursor.execute("SHOW search_path")
227-
assert cursor.fetchone() == ("test",)
228-
229-
with connection.cursor() as cursor:
230-
cursor.execute("SHOW search_path")
231-
assert cursor.fetchone() == ('"$user", public',)
232-
233-
234-
def test_postgres_schema_connection_does_not_catch():
235-
schema = PostgresSchema.create("test")
236-
237-
with pytest.raises(ValueError):
238-
with schema.connection.cursor():
239-
raise ValueError("test")
240-
241-
242-
def test_postgres_schema_connection_no_delete_default():
159+
def test_postgres_schema_no_delete_default():
243160
with pytest.raises(SuspiciousOperation):
244161
PostgresSchema.default.delete()
245162

@@ -261,17 +178,21 @@ def test_postgres_temporary_schema():
261178
def test_postgres_temporary_schema_not_empty():
262179
with pytest.raises(InternalError) as exc_info:
263180
with postgres_temporary_schema("temp") as schema:
264-
with schema.connection.cursor() as cursor:
265-
cursor.execute("CREATE TABLE mytable AS SELECT 'hello world'")
181+
with connection.cursor() as cursor:
182+
cursor.execute(
183+
f"CREATE TABLE {schema.name}.mytable AS SELECT 'hello world'"
184+
)
266185

267186
pg_error = extract_postgres_error(exc_info.value)
268187
assert pg_error.pgcode == errorcodes.DEPENDENT_OBJECTS_STILL_EXIST
269188

270189

271190
def test_postgres_temporary_schema_not_empty_cascade():
272191
with postgres_temporary_schema("temp", cascade=True) as schema:
273-
with schema.connection.cursor() as cursor:
274-
cursor.execute("CREATE TABLE mytable AS SELECT 'hello world'")
192+
with connection.cursor() as cursor:
193+
cursor.execute(
194+
f"CREATE TABLE {schema.name}.mytable AS SELECT 'hello world'"
195+
)
275196

276197
assert not _does_schema_exist(schema.name)
277198

0 commit comments

Comments
 (0)