From 007d6583050ca752a7e81abd3333f4b0c84d8612 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 26 Sep 2026 04:32:03 +0000 Subject: [PATCH 1/4] Exclude views from get_table_names() get_table_names() read every row of information_schema.tables for the schema, so views were returned as tables as well as by get_view_names(). SQLAlchemy defines get_table_names() as base tables only, and callers that list tables and views separately showed each view twice. Filter on table_type = 'BASE TABLE'. has_table() previously relied on get_table_names(); it now checks all relations directly, so it keeps returning True for views as SQLAlchemy 2.0 specifies. --- CHANGES.md | 3 +++ sqlalchemy_cockroachdb/base.py | 19 +++++++++++++------ test/test_introspection.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index de366b0..f32ab25 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,9 @@ Unreleased - include identity column info (#297), and - avoid parse error when reflecting ENUMs (#303). (CRDB 26.3+ required for full compatibility.) +- `get_table_names()` now returns base tables only; views were also listed + and are reported by `get_view_names()`. `has_table()` still returns True for + views, as in SQLAlchemy 2.0. # Version 2.0.4 April 23, 2026 diff --git a/sqlalchemy_cockroachdb/base.py b/sqlalchemy_cockroachdb/base.py index 53cb532..94f21c9 100644 --- a/sqlalchemy_cockroachdb/base.py +++ b/sqlalchemy_cockroachdb/base.py @@ -127,18 +127,25 @@ def get_table_names(self, conn, schema=None, **kw): # v1.1 or earlier. return [row.Table for row in conn.execute(text("SHOW TABLES"))] - # v2.0+ have a good information schema. Use it. + # v2.0+ have a good information schema. Use it. Only base tables are + # returned; views are reported by get_view_names(). + return self._information_schema_relations(conn, schema, base_tables_only=True) + + def _information_schema_relations(self, conn, schema, base_tables_only): + query = "SELECT table_name FROM information_schema.tables WHERE table_schema=:schema" + if base_tables_only: + query += " AND table_type = 'BASE TABLE'" return [ row.table_name - for row in conn.execute( - text("SELECT table_name FROM information_schema.tables WHERE table_schema=:schema"), - {"schema": schema or self.default_schema_name}, - ) + for row in conn.execute(text(query), {"schema": schema or self.default_schema_name}) ] def has_table(self, conn, table, schema=None, info_cache=None): # Upstream implementation needs pg_table_is_visible(). - return any(t == table for t in self.get_table_names(conn, schema=schema)) + # As in SQLAlchemy 2.0, has_table() is also true for views. + if not self._is_v2plus: + return any(t == table for t in self.get_table_names(conn, schema=schema)) + return table in self._information_schema_relations(conn, schema, base_tables_only=False) def get_multi_columns(self, connection, schema, filter_names, scope, kind, **kw): _include_hidden = kw.get("include_hidden", False) diff --git a/test/test_introspection.py b/test/test_introspection.py index a45ac5f..d4eea0d 100644 --- a/test/test_introspection.py +++ b/test/test_introspection.py @@ -7,6 +7,7 @@ UniqueConstraint, CheckConstraint, text, + inspect, ) from sqlalchemy.types import Integer, String, Boolean import sqlalchemy.types as sqltypes @@ -166,3 +167,30 @@ def test_varchar(self): ] for t in types: self._test(t, sqltypes.VARCHAR) + + +class TableNamesTest(fixtures.TestBase): + __requires__ = ("sync_driver",) + + def setup_method(self): + with testing.db.begin() as conn: + conn.execute(text("CREATE TABLE names_base (id INT PRIMARY KEY)")) + conn.execute(text("CREATE VIEW names_view AS SELECT id FROM names_base")) + + def teardown_method(self, method): + with testing.db.begin() as conn: + conn.execute(text("DROP VIEW IF EXISTS names_view")) + conn.execute(text("DROP TABLE IF EXISTS names_base")) + + def test_get_table_names_excludes_views(self): + insp = inspect(testing.db) + table_names = insp.get_table_names() + assert "names_base" in table_names + assert "names_view" not in table_names + assert "names_view" in insp.get_view_names() + + def test_has_table_includes_views(self): + insp = inspect(testing.db) + assert insp.has_table("names_base") + assert insp.has_table("names_view") + assert not insp.has_table("names_absent") From 09a6e2cbb97fd5a3125fe2f847095d22b8658278 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 26 Sep 2026 23:31:17 +0000 Subject: [PATCH 2/4] Reference #310 in CHANGES.md --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index f32ab25..97bff24 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,7 +7,7 @@ Unreleased (CRDB 26.3+ required for full compatibility.) - `get_table_names()` now returns base tables only; views were also listed and are reported by `get_view_names()`. `has_table()` still returns True for - views, as in SQLAlchemy 2.0. + views, as in SQLAlchemy 2.0 (#310). # Version 2.0.4 April 23, 2026 From 74df703494c6e2debb3d0be84b9be3554ae9771d Mon Sep 17 00:00:00 2001 From: Gord Thompson Date: Sun, 27 Sep 2026 06:35:16 -0600 Subject: [PATCH 3/4] Use upstream code for get_table_names --- dev-requirements.txt | 6 +-- sqlalchemy_cockroachdb/base.py | 61 +++++++++++++++----------- sqlalchemy_cockroachdb/requirements.py | 8 ++-- test/test_suite_sqlalchemy.py | 21 +++++---- 4 files changed, 55 insertions(+), 41 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 2d9bd9f..489580d 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -18,7 +18,7 @@ distlib==0.4.3 # via virtualenv docutils==0.23 # via readme-renderer -filelock==4.0.1 +filelock==4.0.4 # via # python-discovery # tox @@ -56,7 +56,7 @@ packaging==26.3 # pyproject-api # tox # twine -platformdirs==4.11.12 +platformdirs==4.12.0 # via # tox # virtualenv @@ -95,7 +95,7 @@ urllib3==2.8.0 # id # requests # twine -virtualenv==21.11.1 +virtualenv==21.13.0 # via tox zipp==4.1.0 # via importlib-metadata diff --git a/sqlalchemy_cockroachdb/base.py b/sqlalchemy_cockroachdb/base.py index 94f21c9..dcc8af5 100644 --- a/sqlalchemy_cockroachdb/base.py +++ b/sqlalchemy_cockroachdb/base.py @@ -120,32 +120,16 @@ def _get_server_version_info(self, conn): # used. return (12, 0, 0) - def get_table_names(self, conn, schema=None, **kw): - # Upstream implementation needs correlated subqueries. - - if not self._is_v2plus: - # v1.1 or earlier. - return [row.Table for row in conn.execute(text("SHOW TABLES"))] - - # v2.0+ have a good information schema. Use it. Only base tables are - # returned; views are reported by get_view_names(). - return self._information_schema_relations(conn, schema, base_tables_only=True) - - def _information_schema_relations(self, conn, schema, base_tables_only): - query = "SELECT table_name FROM information_schema.tables WHERE table_schema=:schema" - if base_tables_only: - query += " AND table_type = 'BASE TABLE'" - return [ - row.table_name - for row in conn.execute(text(query), {"schema": schema or self.default_schema_name}) - ] - - def has_table(self, conn, table, schema=None, info_cache=None): - # Upstream implementation needs pg_table_is_visible(). - # As in SQLAlchemy 2.0, has_table() is also true for views. - if not self._is_v2plus: - return any(t == table for t in self.get_table_names(conn, schema=schema)) - return table in self._information_schema_relations(conn, schema, base_tables_only=False) + def get_table_names(self, connection, schema=None, **kw): + table_names = super().get_table_names(connection, schema=None, **kw) + for k in self.multi_entries_to_ignore: + try: + table_names.remove(k[1]) + except ValueError: + pass + except Exception: + raise + return table_names def get_multi_columns(self, connection, schema, filter_names, scope, kind, **kw): _include_hidden = kw.get("include_hidden", False) @@ -266,6 +250,31 @@ def get_multi_columns(self, connection, schema, filter_names, scope, kind, **kw) to_return.append((table, columns)) return to_return + def get_multi_foreign_keys( + self, + connection, + schema, + filter_names, + scope, + kind, + postgresql_ignore_search_path=False, + **kw, + ): + result = super().get_multi_foreign_keys( + connection, + schema, + filter_names, + scope, + kind, + postgresql_ignore_search_path=False, + **kw, + ) + if schema is None: + result = dict(result) + for k in self.multi_entries_to_ignore: + result.pop(k, None) + return result + def get_indexes(self, conn, table_name, schema=None, **kw): if self._is_v192plus: indexes = super().get_indexes(conn, table_name, schema, **kw) diff --git a/sqlalchemy_cockroachdb/requirements.py b/sqlalchemy_cockroachdb/requirements.py index 6704644..bfd9fdd 100644 --- a/sqlalchemy_cockroachdb/requirements.py +++ b/sqlalchemy_cockroachdb/requirements.py @@ -87,10 +87,6 @@ class Requirements(SuiteRequirementsSQLA, SuiteRequirementsAlembic): emulated_lastrowid = exclusions.open() dbapi_lastrowid = exclusions.open() views = exclusions.open() - schemas = exclusions.skip_if( - lambda config: not config.db.dialect._is_v202plus, - "versions before 20.2 do not suport schemas", - ) implicit_default_schema = exclusions.skip_if( lambda config: not config.db.dialect._is_v202plus, "versions before 20.2 do not suport schemas", @@ -160,6 +156,10 @@ class Requirements(SuiteRequirementsSQLA, SuiteRequirementsAlembic): fk_onupdate = exclusions.closed() fk_onupdate_restrict = exclusions.closed() + @property + def schemas(self): + return exclusions.open() + @property def sync_driver(self): return exclusions.only_if( diff --git a/test/test_suite_sqlalchemy.py b/test/test_suite_sqlalchemy.py index f7be158..6137502 100644 --- a/test/test_suite_sqlalchemy.py +++ b/test/test_suite_sqlalchemy.py @@ -3,8 +3,11 @@ from sqlalchemy.testing.suite import ( ComponentReflectionTest as _ComponentReflectionTest, ) +# (unused: entire class overwritten below) +# from sqlalchemy.testing.suite import ( +# ComputedReflectionTest as _ComputedReflectionTest, +# ) from sqlalchemy.testing.suite import HasIndexTest as _HasIndexTest -from sqlalchemy.testing.suite import HasTableTest as _HasTableTest from sqlalchemy.testing.suite import IntegerTest as _IntegerTest from sqlalchemy.testing.suite import InsertBehaviorTest as _InsertBehaviorTest from sqlalchemy.testing.suite import IsolationLevelTest as _IsolationLevelTest @@ -205,7 +208,7 @@ def test_get_view_names(self): # FWIW, insp.get_view_names() does still work IRL pass - @testing.combinations(True, False, argnames="use_schema") + @testing.combinations(False, argnames="use_schema") @testing.combinations((True, testing.requires.views), False, argnames="views") def test_metadata(self, connection, use_schema, views): if not (config.db.dialect.driver == "asyncpg" and not config.db.dialect._is_v231plus): @@ -217,6 +220,14 @@ def test_not_existing_table(self): pass +class ComputedReflectionTest(): + # expected STORED COMPUTED COLUMN expression to have type int, + # but 'normal / 42' has type decimal + @skip("cockroachdb") + def test_everything(self): + pass + + class HasIndexTest(_HasIndexTest): @skip("cockroachdb") def test_has_index(self): @@ -226,12 +237,6 @@ def test_has_index(self): pass -class HasTableTest(_HasTableTest): - @skip("cockroachdb") - def test_has_table_cache(self): - pass - - class InsertBehaviorTest(_InsertBehaviorTest): @skip("cockroachdb") def test_no_results_for_non_returning_insert(self): From 664ebf6d4f3b68fd569893c25c714b6acf64c9b5 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Wed, 30 Sep 2026 04:31:36 +0000 Subject: [PATCH 4/4] Pass schema and ignore_search_path through to the upstream reflection get_table_names() called the upstream implementation with schema=None, so get_table_names(schema=...) listed the default schema's tables. Pass the requested schema through, and only drop the default-schema entries in multi_entries_to_ignore when no schema was requested, as get_multi_foreign_keys() does. get_multi_foreign_keys() likewise now forwards the caller's postgresql_ignore_search_path instead of False. --- CHANGES.md | 7 ++++--- sqlalchemy_cockroachdb/base.py | 17 ++++++++-------- test/test_introspection.py | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 97bff24..82d17a5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,9 +5,10 @@ Unreleased - include identity column info (#297), and - avoid parse error when reflecting ENUMs (#303). (CRDB 26.3+ required for full compatibility.) -- `get_table_names()` now returns base tables only; views were also listed - and are reported by `get_view_names()`. `has_table()` still returns True for - views, as in SQLAlchemy 2.0 (#310). +- `get_table_names()` and `has_table()` now use the upstream PostgreSQL + implementations. `get_table_names()` returns base tables only; views were + also listed and are reported by `get_view_names()`. `has_table()` still + returns True for views, as in SQLAlchemy 2.0 (#310). # Version 2.0.4 April 23, 2026 diff --git a/sqlalchemy_cockroachdb/base.py b/sqlalchemy_cockroachdb/base.py index dcc8af5..8c9d1c9 100644 --- a/sqlalchemy_cockroachdb/base.py +++ b/sqlalchemy_cockroachdb/base.py @@ -121,14 +121,13 @@ def _get_server_version_info(self, conn): return (12, 0, 0) def get_table_names(self, connection, schema=None, **kw): - table_names = super().get_table_names(connection, schema=None, **kw) - for k in self.multi_entries_to_ignore: - try: - table_names.remove(k[1]) - except ValueError: - pass - except Exception: - raise + table_names = super().get_table_names(connection, schema=schema, **kw) + if schema is None: + for k in self.multi_entries_to_ignore: + try: + table_names.remove(k[1]) + except ValueError: + pass return table_names def get_multi_columns(self, connection, schema, filter_names, scope, kind, **kw): @@ -266,7 +265,7 @@ def get_multi_foreign_keys( filter_names, scope, kind, - postgresql_ignore_search_path=False, + postgresql_ignore_search_path=postgresql_ignore_search_path, **kw, ) if schema is None: diff --git a/test/test_introspection.py b/test/test_introspection.py index d4eea0d..af55efe 100644 --- a/test/test_introspection.py +++ b/test/test_introspection.py @@ -15,6 +15,8 @@ from sqlalchemy.dialects.postgresql import INET from sqlalchemy.dialects.postgresql import INTERVAL from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql.base import PGDialect +from unittest import mock meta = MetaData() @@ -176,9 +178,16 @@ def setup_method(self): with testing.db.begin() as conn: conn.execute(text("CREATE TABLE names_base (id INT PRIMARY KEY)")) conn.execute(text("CREATE VIEW names_view AS SELECT id FROM names_base")) + conn.execute( + text( + "CREATE TABLE names_ref (id INT PRIMARY KEY, " + "base_id INT REFERENCES names_base (id))" + ) + ) def teardown_method(self, method): with testing.db.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS names_ref")) conn.execute(text("DROP VIEW IF EXISTS names_view")) conn.execute(text("DROP TABLE IF EXISTS names_base")) @@ -194,3 +203,30 @@ def test_has_table_includes_views(self): assert insp.has_table("names_base") assert insp.has_table("names_view") assert not insp.has_table("names_absent") + + @testing.requires.schemas + def test_get_table_names_uses_requested_schema(self): + schema = testing.config.test_schema + with testing.db.begin() as conn: + conn.execute(text(f"CREATE TABLE {schema}.names_other (id INT PRIMARY KEY)")) + try: + insp = inspect(testing.db) + table_names = insp.get_table_names(schema=schema) + assert "names_other" in table_names + assert "names_base" not in table_names + assert "names_other" not in insp.get_table_names() + finally: + with testing.db.begin() as conn: + conn.execute(text(f"DROP TABLE IF EXISTS {schema}.names_other")) + + def test_get_foreign_keys_passes_ignore_search_path(self): + insp = inspect(testing.db) + (fk,) = insp.get_foreign_keys("names_ref") + assert fk["referred_table"] == "names_base" + with mock.patch.object( + PGDialect, "get_multi_foreign_keys", return_value={} + ) as upstream: + testing.db.dialect.get_multi_foreign_keys( + None, None, None, None, None, postgresql_ignore_search_path=True + ) + assert upstream.call_args.kwargs["postgresql_ignore_search_path"] is True