Skip to content

Commit 498124a

Browse files
committed
Add support to schema editor for cloning table into schema
1 parent 316ccfe commit 498124a

File tree

7 files changed

+989
-51
lines changed

7 files changed

+989
-51
lines changed

psqlextra/backend/introspection.py

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from contextlib import contextmanager
12
from dataclasses import dataclass
2-
from typing import List, Optional, Tuple
3+
from typing import Dict, List, Optional, Tuple
4+
5+
from django.db import transaction
36

47
from psqlextra.types import PostgresPartitioningMethod
58

@@ -48,6 +51,22 @@ def partition_by_name(
4851
class PostgresIntrospection(base_impl.introspection()):
4952
"""Adds introspection features specific to PostgreSQL."""
5053

54+
# TODO: This class is a mess, both here and in the
55+
# the base.
56+
#
57+
# Some methods return untyped dicts, some named tuples,
58+
# some flat lists of strings. It's horribly inconsistent.
59+
#
60+
# Most methods are poorly named. For example; `get_table_description`
61+
# does not return a complete table description. It merely returns
62+
# the columns.
63+
#
64+
# We do our best in this class to stay consistent with
65+
# the base in Django by respecting its naming scheme
66+
# and commonly used return types. Creating an API that
67+
# matches the look&feel from the Django base class
68+
# is more important than fixing those issues.
69+
5170
def get_partitioned_tables(
5271
self, cursor
5372
) -> PostgresIntrospectedPartitonedTable:
@@ -172,6 +191,9 @@ def get_partition_key(self, cursor, table_name: str) -> List[str]:
172191
cursor.execute(sql, (table_name,))
173192
return [row[0] for row in cursor.fetchall()]
174193

194+
def get_columns(self, cursor, table_name: str):
195+
return self.get_table_description(cursor, table_name)
196+
175197
def get_constraints(self, cursor, table_name: str):
176198
"""Retrieve any constraints or keys (unique, pk, fk, check, index)
177199
across one or more columns.
@@ -202,15 +224,93 @@ def get_constraints(self, cursor, table_name: str):
202224
def get_table_locks(self, cursor) -> List[Tuple[str, str, str]]:
203225
cursor.execute(
204226
"""
205-
SELECT
206-
n.nspname,
207-
t.relname,
208-
l.mode
209-
FROM pg_locks l
210-
INNER JOIN pg_class t ON t.oid = l.relation
211-
INNER JOIN pg_namespace n ON n.oid = t.relnamespace
212-
WHERE t.relnamespace >= 2200
213-
ORDER BY n.nspname, t.relname, l.mode"""
227+
SELECT
228+
n.nspname,
229+
t.relname,
230+
l.mode
231+
FROM pg_locks l
232+
INNER JOIN pg_class t ON t.oid = l.relation
233+
INNER JOIN pg_namespace n ON n.oid = t.relnamespace
234+
WHERE t.relnamespace >= 2200
235+
ORDER BY n.nspname, t.relname, l.mode
236+
"""
214237
)
215238

216239
return cursor.fetchall()
240+
241+
def get_storage_settings(self, cursor, table_name: str) -> Dict[str, str]:
242+
sql = """
243+
SELECT
244+
unnest(c.reloptions || array(select 'toast.' || x from pg_catalog.unnest(tc.reloptions) x))
245+
FROM
246+
pg_catalog.pg_class c
247+
LEFT JOIN
248+
pg_catalog.pg_class tc ON (c.reltoastrelid = tc.oid)
249+
LEFT JOIN
250+
pg_catalog.pg_am am ON (c.relam = am.oid)
251+
WHERE
252+
c.relname::text = %s
253+
"""
254+
255+
cursor.execute(sql, (table_name,))
256+
257+
storage_settings = {}
258+
for row in cursor.fetchall():
259+
# It's hard to believe, but storage settings are really
260+
# represented as `key=value` strings in Postgres.
261+
# See: https://www.postgresql.org/docs/current/catalog-pg-class.html
262+
name, value = row[0].split("=")
263+
storage_settings[name] = value
264+
265+
return storage_settings
266+
267+
def get_relations(self, cursor, table_name: str):
268+
"""Gets a dictionary {field_name: (field_name_other_table,
269+
other_table)} representing all relations in the specified table.
270+
271+
This is overriden because the query in Django does not handle
272+
relations between tables in different schemas properly.
273+
"""
274+
275+
cursor.execute(
276+
"""
277+
SELECT a1.attname, c2.relname, a2.attname
278+
FROM pg_constraint con
279+
LEFT JOIN pg_class c1 ON con.conrelid = c1.oid
280+
LEFT JOIN pg_class c2 ON con.confrelid = c2.oid
281+
LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1]
282+
LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1]
283+
WHERE
284+
con.conrelid = %s::regclass AND
285+
con.contype = 'f' AND
286+
pg_catalog.pg_table_is_visible(c1.oid)
287+
""",
288+
[table_name],
289+
)
290+
return {row[0]: (row[2], row[1]) for row in cursor.fetchall()}
291+
292+
@contextmanager
293+
def in_search_path(self, search_path: List[str]):
294+
"""Changes the Postgres `search_path` within the context and switches
295+
it back when it exits."""
296+
297+
# Wrap in a transaction so a savepoint is created. If
298+
# something goes wrong, the `SET LOCAL search_path`
299+
# statement will be rolled back.
300+
with transaction.atomic(using=self.connection.alias):
301+
with self.connection.cursor() as cursor:
302+
cursor.execute("SHOW search_path")
303+
(original_search_path,) = cursor.fetchone()
304+
305+
# Syntax in Postgres is a bit weird here. It isn't really
306+
# a list of names like in `WHERE bla in (val1, val2)`.
307+
placeholder = ", ".join(["%s" for _ in search_path])
308+
cursor.execute(
309+
f"SET LOCAL search_path = {placeholder}", search_path
310+
)
311+
312+
yield self
313+
314+
cursor.execute(
315+
f"SET LOCAL search_path = {original_search_path}"
316+
)

psqlextra/backend/operations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ class PostgresOperations(base_impl.operations()):
2121
SQLUpdateCompiler,
2222
SQLInsertCompiler,
2323
]
24+
25+
def default_schema_name(self) -> str:
26+
return "public"

0 commit comments

Comments
 (0)