From 429002ccc41d56e2f6bb86c57e1b70e97385b97f Mon Sep 17 00:00:00 2001 From: Oleg Ovcharuk Date: Wed, 3 Jun 2026 13:04:20 +0300 Subject: [PATCH] Ignore table schema in reflection and SQL compilation (#114) YDB connections are bound to a single database and have no schema namespaces (supports_schemas=False). Accept and ignore the schema argument in reflection, and never emit a schema qualifier when compiling SQL, so generic two-tier SQLAlchemy tooling works. --- test/test_inspect.py | 29 ++++++++++++++++++++++ ydb_sqlalchemy/sqlalchemy/__init__.py | 14 ++--------- ydb_sqlalchemy/sqlalchemy/compiler/base.py | 8 ++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/test/test_inspect.py b/test/test_inspect.py index ae32004..b6640a1 100644 --- a/test/test_inspect.py +++ b/test/test_inspect.py @@ -61,6 +61,35 @@ def test_has_table(self, connection): assert inspect.has_table("test") assert not inspect.has_table("foo") + def test_reflection_ignores_schema(self, connection): + # supports_schemas=False: the `schema` argument is ignored regardless of value, + # so reflection always targets the connected database (the convention two-tier + # SQLAlchemy tooling relies on). + inspect = sa.inspect(connection) + bound_database = connection.connection.driver_connection.database.strip("/") + + for schema in (bound_database, "some_other_database"): + assert "test" in inspect.get_table_names(schema=schema) + assert inspect.has_table("test", schema=schema) + assert inspect.get_columns("test", schema=schema) + + def test_compile_ignores_schema_prefix(self, connection): + bound_database = connection.connection.driver_connection.database.strip("/") + + # A table addressed via the connected database as schema (the way two-tier + # tooling does) must compile without a schema prefix and execute against YDB. + t = sa.Table("test", sa.MetaData(), schema=bound_database, autoload_with=connection) + stmt = sa.select(sa.func.count()).select_from(t) + compiled = str(stmt.compile(connection)) + assert f"{bound_database}.`test`" not in compiled + assert f"{bound_database}.test" not in compiled + connection.execute(stmt).scalar() + + # Any other schema is likewise dropped rather than leaking into the path. + foreign = sa.Table("test", sa.MetaData(), Column("id", Integer), schema="some_other_database") + compiled_foreign = str(sa.select(sa.func.count()).select_from(foreign).compile(connection)) + assert "some_other_database." not in compiled_foreign + def test_view_reflection(self, connection, test_view): view_name = test_view inspect = sa.inspect(connection) diff --git a/ydb_sqlalchemy/sqlalchemy/__init__.py b/ydb_sqlalchemy/sqlalchemy/__init__.py index 7c81153..affed36 100644 --- a/ydb_sqlalchemy/sqlalchemy/__init__.py +++ b/ydb_sqlalchemy/sqlalchemy/__init__.py @@ -250,13 +250,9 @@ def __init__( self._add_declare_for_yql_stmt_vars = _add_declare_for_yql_stmt_vars self._statement_prefixes = tuple(_statement_prefixes_list) if _statement_prefixes_list else () - def _ensure_schema_unsupported(self, schema): - if schema: - raise ydb_dbapi.NotSupportedError("unsupported on non empty schema") - def _describe_table(self, connection, table_name, schema=None) -> ydb.TableDescription: - self._ensure_schema_unsupported(schema) - + # supports_schemas=False: the schema argument is ignored, reflection always + # targets the connected database. qt = table_name if isinstance(table_name, str) else table_name.name raw_conn = connection.connection try: @@ -266,15 +262,11 @@ def _describe_table(self, connection, table_name, schema=None) -> ydb.TableDescr @reflection.cache def get_view_names(self, connection, schema=None, **kw): - self._ensure_schema_unsupported(schema) - raw_conn = connection.connection return raw_conn.get_view_names() @reflection.cache def get_view_definition(self, connection, view_name, schema=None, **kw): - self._ensure_schema_unsupported(schema) - quoted_view_name = self.identifier_preparer.quote(view_name) result = connection.execute(sa.text(f"SHOW CREATE VIEW {quoted_view_name}")) row = result.fetchone() @@ -301,8 +293,6 @@ def get_columns(self, connection, table_name, schema=None, **kw): @reflection.cache def get_table_names(self, connection, schema=None, **kw): - self._ensure_schema_unsupported(schema) - raw_conn = connection.connection return raw_conn.get_table_names() diff --git a/ydb_sqlalchemy/sqlalchemy/compiler/base.py b/ydb_sqlalchemy/sqlalchemy/compiler/base.py index d7cf27e..f4c5878 100644 --- a/ydb_sqlalchemy/sqlalchemy/compiler/base.py +++ b/ydb_sqlalchemy/sqlalchemy/compiler/base.py @@ -270,6 +270,10 @@ class BaseYqlCompiler(StrSQLCompiler): def get_from_hint_text(self, table, text): return text + def visit_table(self, table, use_schema=True, **kwargs): + # supports_schemas=False: never emit a schema qualifier in FROM/hint clauses. + return super().visit_table(table, use_schema=False, **kwargs) + def group_by_clause(self, select, **kw): # Hack to ensure it is possible to define labels in groupby. kw.update(within_columns_clause=True) @@ -530,5 +534,9 @@ def __init__(self, dialect): final_quote="`", ) + def format_table(self, table, use_schema=True, name=None): + # supports_schemas=False: never emit a schema qualifier in DML/DDL. + return super().format_table(table, use_schema=False, name=name) + def format_index(self, index: sa.Index) -> str: return super().format_index(index).replace("/", "_")