Skip to content

Commit 72dde8c

Browse files
committed
Operate in exactly one schema, and filter every catalog query to it
Unqualified DDL and DML went wherever search_path happened to point, while catalog inspection was a mixture of hard-coded 'public' and no filter at all. With a relation of the same name in two schemas the answers came from both: _column_types unioned their columns (or raised "Type mismatch"), an index or constraint in the other schema counted as present, _all_tablenames listed the name twice, and table_sizes reported only public whatever the session was using. PostgresDatabase now takes schema="public", validates it as an identifier once in the constructor, and pins search_path to it in _configure_session -- which runs for the first connection and for every replacement, so a reconnect cannot come back pointing somewhere else. It is deliberately not part of _connect_kwargs: psycopg.connect has no such parameter, and passing it there would reach the driver. Every catalog query is then filtered to that schema, binding it as a value rather than interpolating it: _table_exists, _all_tablenames, _index_exists, _list_indexes, _relation_exists, _constraint_exists, _list_constraints, _column_types, _relation_columns, refresh_tables' column discovery, the read-only and knowls capability probes, _grantees, the legacy-extras check, table_sizes, tablespaces, _check_tmp_leftovers' two probes, the metadata bootstrap's existing-table set, _approx_most_common and dbdiff's column reader. _schema_relations and _approx_most_common previously asked the server with current_schema(); they now bind the same value as everything else, so there is one notion of which schema this is. The userdb.users grant probe keeps its own schema: that one is deliberately about a different schema, not about this database's.
1 parent 055373a commit 72dde8c

9 files changed

Lines changed: 322 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,19 @@ hardening standalone use; the highlights:
399399
through the one helper they share; `copy_from` analyzes the live table it
400400
loaded into.
401401

402+
- **A database operates in exactly one schema.** `PostgresDatabase` takes a
403+
`schema=` argument (default `"public"`, so nothing changes for existing
404+
deployments), validates it as an identifier, and pins `search_path` to it on
405+
the first connection and on every replacement. Catalog inspection was
406+
previously a mixture of hard-coded `'public'` and no filter at all -- 30-odd
407+
queries across `pg_tables`, `pg_indexes`, `pg_class`, `pg_constraint` and
408+
`information_schema` -- so with two schemas holding a relation of the same
409+
name, column discovery could mix their columns, an index or constraint in
410+
the other schema counted as present, and `_all_tablenames` listed the name
411+
twice. Every one is now filtered to the selected schema, which is bound as a
412+
value rather than interpolated. *Migration:* none unless you were relying on
413+
psycodict seeing relations outside `public`, which it did only by accident.
414+
402415
### Release candidates
403416

404417
1.0.0 is published as a sequence of release candidates first. `pip` ignores

DataManagement.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ Copies the column layout, `label_col`, `sort`, `id_ordered`, `id` type and descr
6464

6565
psycodict keeps its own bookkeeping in a handful of tables that live alongside your search tables:
6666

67+
psycodict operates in one PostgreSQL schema, `public` unless the constructor is
68+
given another (`PostgresDatabase(schema="myschema")`). Every relation it
69+
creates goes there, every relation it looks for is looked for there, and every
70+
catalog query is filtered to it, so a relation of the same name in another
71+
schema is neither mistaken for one of these nor merged with it. The schema is
72+
pinned in each connection's `search_path`, including replacement connections
73+
after a reconnect.
74+
6775
* **`meta_tables`** — one row per search table, holding `name`, `sort`, `count_cutoff`, `id_ordered`, `out_of_order`, `stats_valid`, `label_col`, `total`, `important` and `include_nones`. This is the source of truth psycodict reads on connection to reconstruct each table object.
6876
* **`meta_indexes`** and **`meta_constraints`** — one row per index / constraint, recording how to rebuild it. `reload` and `restore_indexes` rebuild from these rows, **not** from whatever is physically on the table (see [reload](#reload)).
6977
* **`meta_tables_hist`**, **`meta_indexes_hist`**, **`meta_constraints_hist`** — versioned history of the three tables above, so that `reload_meta`/`revert_meta` can roll a table's metadata forward and back.

psycodict/base.py

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -423,14 +423,25 @@ def _table_exists(self, tablename):
423423
424424
- ``tablename`` -- a string, the name of the table
425425
"""
426-
cur = self._execute(SQL("SELECT 1 FROM pg_tables where tablename=%s"), [tablename], silent=True)
426+
cur = self._execute(
427+
SQL("SELECT 1 FROM pg_tables WHERE schemaname = %s AND tablename = %s"),
428+
[self._db.schema, tablename],
429+
silent=True,
430+
)
427431
return cur.fetchone() is not None
428432

429433
def _all_tablenames(self):
430434
"""
431435
Return all (postgres) table names in the database
432436
"""
433-
return [rec[0] for rec in self._execute(SQL("SELECT tablename FROM pg_tables ORDER BY tablename"), silent=True)]
437+
return [
438+
rec[0]
439+
for rec in self._execute(
440+
SQL("SELECT tablename FROM pg_tables WHERE schemaname = %s ORDER BY tablename"),
441+
[self._db.schema],
442+
silent=True,
443+
)
444+
]
434445

435446
def _get_locks(self):
436447
return self._execute(SQL(
@@ -535,15 +546,18 @@ def _index_exists(self, indexname, tablename=None):
535546
"""
536547
if tablename:
537548
cur = self._execute(
538-
SQL("SELECT 1 FROM pg_indexes WHERE indexname = %s AND tablename = %s"),
539-
[indexname, tablename],
549+
SQL(
550+
"SELECT 1 FROM pg_indexes "
551+
"WHERE schemaname = %s AND indexname = %s AND tablename = %s"
552+
),
553+
[self._db.schema, indexname, tablename],
540554
silent=True,
541555
)
542556
return cur.fetchone() is not None
543557
else:
544558
cur = self._execute(
545-
SQL("SELECT tablename FROM pg_indexes WHERE indexname=%s"),
546-
[indexname],
559+
SQL("SELECT tablename FROM pg_indexes WHERE schemaname = %s AND indexname = %s"),
560+
[self._db.schema, indexname],
547561
silent=True,
548562
)
549563
table = cur.fetchone()
@@ -560,7 +574,13 @@ def _relation_exists(self, name):
560574
561575
- ``name`` -- a string, the name of the relation
562576
"""
563-
cur = self._execute(SQL("SELECT 1 FROM pg_class where relname = %s"), [name])
577+
cur = self._execute(
578+
SQL(
579+
"SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
580+
"WHERE n.nspname = %s AND c.relname = %s"
581+
),
582+
[self._db.schema, name],
583+
)
564584
return cur.fetchone() is not None
565585

566586
def _constraint_exists(self, constraintname, tablename=None):
@@ -582,19 +602,19 @@ def _constraint_exists(self, constraintname, tablename=None):
582602
cur = self._execute(
583603
SQL(
584604
"SELECT 1 from information_schema.table_constraints "
585-
"WHERE table_name=%s and constraint_name=%s"
605+
"WHERE table_schema = %s AND table_name = %s AND constraint_name = %s"
586606
),
587-
[tablename, constraintname],
607+
[self._db.schema, tablename, constraintname],
588608
silent=True,
589609
)
590610
return cur.fetchone() is not None
591611
else:
592612
cur = self._execute(
593613
SQL(
594614
"SELECT table_name from information_schema.table_constraints "
595-
"WHERE constraint_name=%s"
615+
"WHERE table_schema = %s AND constraint_name = %s"
596616
),
597-
[constraintname],
617+
[self._db.schema, constraintname],
598618
silent=True,
599619
)
600620
table = cur.fetchone()
@@ -608,8 +628,8 @@ def _list_indexes(self, tablename):
608628
Lists built index names on the search table ``tablename``
609629
"""
610630
cur = self._execute(
611-
SQL("SELECT indexname FROM pg_indexes WHERE tablename = %s"),
612-
[tablename],
631+
SQL("SELECT indexname FROM pg_indexes WHERE schemaname = %s AND tablename = %s"),
632+
[self._db.schema, tablename],
613633
silent=True,
614634
)
615635
return [elt[0] for elt in cur]
@@ -629,9 +649,9 @@ def _list_constraints(self, tablename):
629649
" ON rel.oid = con.conrelid "
630650
"INNER JOIN pg_catalog.pg_namespace nsp "
631651
" ON nsp.oid = connamespace "
632-
"WHERE rel.relname = %s"
652+
"WHERE nsp.nspname = %s AND rel.relname = %s"
633653
),
634-
[tablename],
654+
[self._db.schema, tablename],
635655
silent=True,
636656
)
637657
return [elt[0] for elt in cur]
@@ -809,9 +829,9 @@ def _column_types(self, table_name, data_types=None):
809829
cur = self._execute(
810830
SQL(
811831
"SELECT column_name, udt_name::regtype FROM information_schema.columns "
812-
"WHERE table_name = %s ORDER BY ordinal_position"
832+
"WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position"
813833
),
814-
[tname],
834+
[self._db.schema, tname],
815835
)
816836
else:
817837
cur = data_types[tname]
@@ -838,8 +858,11 @@ def _relation_columns(self, table):
838858
one about columns.
839859
"""
840860
cur = self._execute(
841-
SQL("SELECT column_name FROM information_schema.columns WHERE table_name = %s"),
842-
[table],
861+
SQL(
862+
"SELECT column_name FROM information_schema.columns "
863+
"WHERE table_schema = %s AND table_name = %s"
864+
),
865+
[self._db.schema, table],
843866
silent=True,
844867
commit=False,
845868
)

psycodict/database.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
check_new_table_name,
4848
derived_identifier,
4949
physical_table_name,
50+
validate_schema_name,
5051
validate_search_table_name,
5152
validate_search_table_registry,
5253
)
@@ -347,6 +348,11 @@ def _configure_session(self, conn):
347348
# Note that it has some global effects, since register_adapter
348349
# is not limited to just one connection
349350
setup_connection(conn)
351+
# Pin the schema first: everything below, and every statement this
352+
# connection later runs, resolves unqualified names in it. pg_catalog
353+
# is still searched -- PostgreSQL puts it first implicitly when it is
354+
# not named -- so the built-in types and functions stay reachable.
355+
conn.execute("SELECT set_config('search_path', %s, false)", [self.schema])
350356
for name, value in self._session_settings.items():
351357
# set_config takes both as bound values, so nothing is interpolated
352358
conn.execute("SELECT set_config(%s, %s, false)", [name, str(value)])
@@ -387,7 +393,7 @@ def query(sql, args):
387393
"SELECT count(*) FROM information_schema.role_table_grants "
388394
"WHERE grantee = %s AND table_schema = %s "
389395
"AND privilege_type IN (" + ",".join(["%s"] * len(privileges)) + ")",
390-
[user, "public"] + privileges,
396+
[user, self.schema] + privileges,
391397
)
392398
read_only = rows[0][0] == 0
393399

@@ -405,12 +411,12 @@ def query(sql, args):
405411
rows = sorted(query(
406412
"SELECT table_name, privilege_type "
407413
"FROM information_schema.role_table_grants "
408-
"WHERE grantee = %s AND table_name IN ("
414+
"WHERE grantee = %s AND table_schema = %s AND table_name IN ("
409415
+ ",".join(["%s"] * len(knowls_tables))
410416
+ ") AND privilege_type IN ("
411417
+ ",".join(["%s"] * len(privileges))
412418
+ ")",
413-
[user] + knowls_tables + privileges,
419+
[user, self.schema] + knowls_tables + privileges,
414420
))
415421
read_and_write_knowls = rows == sorted(
416422
[(table, priv) for table in knowls_tables for priv in privileges]
@@ -522,11 +528,19 @@ def _register_object(self, obj):
522528
self._objects.append(obj)
523529

524530
def __init__(self, config=None, secretsfile=None, create=False, upgrade=False,
525-
session_settings=None, grant_policy=None, **kwargs):
531+
session_settings=None, grant_policy=None, schema="public", **kwargs):
526532
if config is None:
527533
from .config import Configuration
528534
config = Configuration()
529535
self.config = config
536+
# The one schema this database operates in. Every relation psycodict
537+
# creates goes here, every relation it looks for is looked for here,
538+
# and every catalog query is filtered to it -- so that a table of the
539+
# same name in another schema can neither stand in for one of these nor
540+
# be merged with it. Checked once, here, rather than at each use.
541+
# Deliberately not part of _connect_kwargs: it is psycodict's own
542+
# setting, and psycopg.connect has no such parameter.
543+
self.schema = validate_schema_name(schema)
530544
self.server_side_counter = 0
531545
self._nocommit_stack = 0
532546
self._silenced = False
@@ -576,9 +590,9 @@ def __init__(self, config=None, secretsfile=None, create=False, upgrade=False,
576590
# Refuse to run against a database that still uses the removed
577591
# search/extras table split
578592
legacy = self._execute(SQL(
579-
"SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' "
593+
"SELECT 1 FROM information_schema.columns WHERE table_schema = %s "
580594
"AND table_name = 'meta_tables' AND column_name = 'has_extras'"
581-
))
595+
), [self.schema])
582596
if legacy.rowcount:
583597
cur = self._execute(SQL("SELECT name FROM meta_tables WHERE has_extras"))
584598
if cur.rowcount:
@@ -637,8 +651,9 @@ def refresh_tables(self):
637651
"""
638652
cur = self._execute(SQL(
639653
"SELECT table_name, column_name, udt_name::regtype "
640-
"FROM information_schema.columns ORDER BY table_name, ordinal_position"
641-
))
654+
"FROM information_schema.columns WHERE table_schema = %s "
655+
"ORDER BY table_name, ordinal_position"
656+
), [self.schema])
642657
data_types = {}
643658
for table_name, column_name, regtype in cur:
644659
if table_name not in data_types:
@@ -794,9 +809,9 @@ def _grantees(self, table_name):
794809
cur = self._execute(
795810
SQL(
796811
"SELECT DISTINCT grantee FROM information_schema.role_table_grants "
797-
"WHERE table_name = %s AND grantee <> grantor"
812+
"WHERE table_schema = %s AND table_name = %s AND grantee <> grantor"
798813
),
799-
[table_name],
814+
[self.schema, table_name],
800815
silent=True,
801816
)
802817
return {rec[0] for rec in cur}
@@ -925,12 +940,12 @@ def _schema_relations(self, relkinds=_TABLE_RELKINDS):
925940
query = (
926941
"SELECT c.relname FROM pg_class c "
927942
"JOIN pg_namespace n ON n.oid = c.relnamespace "
928-
"WHERE n.nspname = current_schema()"
943+
"WHERE n.nspname = %s"
929944
)
930-
values = None
945+
values = [self._db.schema]
931946
if relkinds is not None:
932947
query += " AND c.relkind = ANY(%s)"
933-
values = [list(relkinds)]
948+
values.append(list(relkinds))
934949
cur = self._execute(SQL(query), values, silent=True)
935950
return {rec[0] for rec in cur}
936951

@@ -1033,10 +1048,10 @@ def table_sizes(self):
10331048
pg_total_relation_size(reltoastrelid) AS toast_bytes
10341049
FROM pg_class c
10351050
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
1036-
WHERE n.nspname = 'public' AND relkind = 'r'
1051+
WHERE n.nspname = %s AND relkind = 'r'
10371052
) a"""
10381053
sizes = defaultdict(lambda: defaultdict(int))
1039-
cur = self._execute(SQL(query))
1054+
cur = self._execute(SQL(query), [self.schema])
10401055
for (
10411056
table_name,
10421057
row_estimate,
@@ -1337,8 +1352,8 @@ def _bootstrap_meta(self):
13371352
existing = {
13381353
rec[0] for rec in self._execute(SQL(
13391354
"SELECT table_name FROM information_schema.tables "
1340-
"WHERE table_schema = 'public'"
1341-
))
1355+
"WHERE table_schema = %s"
1356+
), [self.schema])
13421357
}
13431358
stored, _ = self._stored_meta_format()
13441359
fmt = META_FORMAT if stored is None else min(stored, META_FORMAT)
@@ -2411,7 +2426,13 @@ def tablespaces(self):
24112426
"""
24122427
Returns a dictionary giving giving the tablespace for all tables
24132428
"""
2414-
D = {rec[0]: rec[1] for rec in self._execute(SQL("SELECT tablename, tablespace FROM pg_tables"))}
2429+
D = {
2430+
rec[0]: rec[1]
2431+
for rec in self._execute(
2432+
SQL("SELECT tablename, tablespace FROM pg_tables WHERE schemaname = %s"),
2433+
[self.schema],
2434+
)
2435+
}
24152436
return {name: space if space else "" for (name, space) in D.items()}
24162437

24172438
def compare(self, other, tables=None, row_counts=True, null_counts=False, exact=False):

psycodict/dbdiff.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ def _column_types(db, names):
8888
"FROM pg_attribute a "
8989
"JOIN pg_class c ON a.attrelid = c.oid "
9090
"JOIN pg_namespace n ON c.relnamespace = n.oid "
91-
"WHERE n.nspname = 'public' AND c.relkind = 'r' "
91+
"WHERE n.nspname = %s AND c.relkind = 'r' "
9292
"AND a.attnum > 0 AND NOT a.attisdropped"
93-
))
93+
), [db.schema])
9494
columns = {}
9595
for table_name, column_name, typ in cur:
9696
if table_name in names:

psycodict/statstable.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,11 +1728,14 @@ def _approx_most_common(self, col, n):
17281728
CROSS JOIN (
17291729
SELECT c.reltuples FROM pg_class c
17301730
JOIN pg_namespace n ON n.oid = c.relnamespace
1731-
WHERE n.nspname = current_schema() AND c.relname = %s) c
1732-
WHERE schemaname = current_schema() AND tablename = %s AND attname = %s
1731+
WHERE n.nspname = %s AND c.relname = %s) c
1732+
WHERE schemaname = %s AND tablename = %s AND attname = %s
17331733
ORDER BY v.ord LIMIT %s"""
17341734
).format(Identifier(col), column_type_sql(self.table.col_type[col]))
1735-
cur = self._execute(selecter, [self.search_table, self.search_table, col, n])
1735+
schema = self._db.schema
1736+
cur = self._execute(
1737+
selecter, [schema, self.search_table, schema, self.search_table, col, n]
1738+
)
17361739
return [tuple(x) for x in cur]
17371740

17381741
def _common_cols(self, threshold=700):

psycodict/table.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,10 @@ def _get_tablespace(self):
328328
"""
329329
Determine the tablespace hosting this table (which is then used for indexes and constraints)
330330
"""
331-
cur = self._execute(SQL("SELECT tablespace FROM pg_tables WHERE tablename=%s"), [self.search_table])
331+
cur = self._execute(
332+
SQL("SELECT tablespace FROM pg_tables WHERE schemaname = %s AND tablename = %s"),
333+
[self._db.schema, self.search_table],
334+
)
332335
return cur.fetchone()[0]
333336

334337
def _create_index_statement(self, name, table, type, columns, modifiers, storage_params, whereclause=None):
@@ -2063,9 +2066,11 @@ def _check_tmp_leftovers(self, clone_tables=None):
20632066
SQL(
20642067
"SELECT rel.relname, con.conname FROM pg_constraint con "
20652068
"JOIN pg_class rel ON rel.oid = con.conrelid "
2066-
"WHERE rel.relname = ANY(%s) AND con.conname ~ %s"
2069+
"JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace "
2070+
"WHERE nsp.nspname = %s AND rel.relname = ANY(%s) "
2071+
"AND con.conname ~ %s"
20672072
),
2068-
[tables, pattern],
2073+
[self._db.schema, tables, pattern],
20692074
silent=True,
20702075
)
20712076
]
@@ -2078,9 +2083,9 @@ def _check_tmp_leftovers(self, clone_tables=None):
20782083
for tbl, name in self._execute(
20792084
SQL(
20802085
"SELECT tablename, indexname FROM pg_indexes "
2081-
"WHERE tablename = ANY(%s) AND indexname ~ %s"
2086+
"WHERE schemaname = %s AND tablename = ANY(%s) AND indexname ~ %s"
20822087
),
2083-
[tables, pattern],
2088+
[self._db.schema, tables, pattern],
20842089
silent=True,
20852090
)
20862091
if (tbl, name) not in found

0 commit comments

Comments
 (0)