From: Federico Caselli Date: Thu, 21 May 2026 21:05:07 +0000 (+0200) Subject: Add has_multi_table reflection method X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=e32fe92a836b7146c76d4ad9b72ca815831bf35d;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add has_multi_table reflection method Created new reflection method :meth:`_engine.Inspector.has_multi_table` to check the existence of multiple tables at once, allowing for better performance when checking many tables. Like the other "multi" reflection methods, the default dialect offers a default implementation that just call the single method in a loop. Backends that wish to take advantage of this new method can implement it in their dialects. The PostgreSQL, Oracle and SQL Server dialects have been updated to use this new method. The implementation of :meth:`_schema.MetaData.create_all` has been updated to make use of this new method to check the existence of the tables, reducing the number of round trips to the database when creating many tables. Fixes: #13311 Change-Id: I75a660fc9d5d5df88a277a792c56058b8355ed0c --- diff --git a/doc/build/changelog/unreleased_21/13311.rst b/doc/build/changelog/unreleased_21/13311.rst new file mode 100644 index 0000000000..79d670f2bd --- /dev/null +++ b/doc/build/changelog/unreleased_21/13311.rst @@ -0,0 +1,15 @@ +.. change:: + :tags: feature, reflection, performance + :tickets: 13311 + + Created new reflection method :meth:`_engine.Inspector.has_multi_table` + to check the existence of multiple tables at once, allowing for + better performance when checking many tables. Like the other "multi" + reflection methods, the default dialect offers a default implementation + that just call the single method in a loop. Backends that wish to take + advantage of this new method can implement it in their dialects. + The PostgreSQL, Oracle and SQL Server dialects have been updated to use + this new method. + The implementation of :meth:`_schema.MetaData.create_all` has been updated + to make use of this new method to check the existence of the tables, + reducing the number of round trips to the database when creating many tables. diff --git a/lib/sqlalchemy/dialects/mssql/base.py b/lib/sqlalchemy/dialects/mssql/base.py index c9679f3007..b856361512 100644 --- a/lib/sqlalchemy/dialects/mssql/base.py +++ b/lib/sqlalchemy/dialects/mssql/base.py @@ -3373,11 +3373,29 @@ class MSDialect(default._BackendsMultiReflection, default.DefaultDialect): else: return self.schema_name + @reflection.cache @_db_plus_owner def has_table(self, connection, tablename, dbname, owner, schema, **kw): self._ensure_has_table_connection(connection) - return self._internal_has_table(connection, tablename, owner, **kw) + return self._internal_has_multi_table( + connection, [tablename], owner, schema, **kw + )[schema, tablename] + + def has_multi_table(self, connection, table_names, schema=None, **kw): + # this does not really matche the signature of _db_plus_owner_multi + # so manually do the unpacking here + dbname, owner = _owner_plus_db(self, schema) + return _switch_db( + dbname, + connection, + self._internal_has_multi_table, + connection, + table_names, + owner, + schema, + **kw, + ).items() @reflection.cache @_db_plus_owner @@ -3452,37 +3470,38 @@ class MSDialect(default._BackendsMultiReflection, default.DefaultDialect): view_names = [r[0] for r in connection.execute(s)] return view_names - @reflection.cache - def _internal_has_table(self, connection, tablename, owner, **kw): - if tablename.startswith("#"): # temporary table - # mssql does not support temporary views - # SQL Error [4103] [S0001]: "#v": Temporary views are not allowed - return bool( - connection.scalar( - # U filters on user tables only. - text("SELECT object_id(:table_name, 'U')"), - {"table_name": f"tempdb.dbo.[{tablename}]"}, - ) - ) - else: - tables = ischema.tables - - s = sql.select(tables.c.table_name).where( - sql.and_( - sql.or_( - tables.c.table_type == "BASE TABLE", - tables.c.table_type == "VIEW", - ), - tables.c.table_name == tablename, - ) + def _internal_has_multi_table( + self, connection, table_names, owner, schema, **kw + ): + regular_names, multi_object_names, temp_names = ( + self._partition_filter_names( + connection, owner, table_names, ObjectScope.ANY, ObjectKind.ANY ) + ) - if owner: - s = s.where(tables.c.table_schema == owner) + result = {} + if multi_object_names: + # in multi_object_names we already have the names that exist in + # the database, no need to do another query here + name_map = self._multi_name_map(regular_names) + for server_name in multi_object_names: + result[(schema, name_map(server_name))] = True - c = connection.execute(s) + if temp_names: + # mssql does not support temporary views + # U filters on user tables only. + query = text("SELECT object_id(:table_name, 'U')") + for temp_name in temp_names: + result[(schema, temp_name)] = bool( + connection.scalar( + query, + {"table_name": f"tempdb..[{temp_name}]"}, + ) + ) - return c.first() is not None + for name in table_names: + result.setdefault((schema, name), False) + return result @reflection.cache @_db_plus_owner @@ -3853,6 +3872,34 @@ index_info AS ( # --- multi-reflection API --- + @lru_cache + def _partition_filter_names_query(self, add_filter_names: bool): + sys_objects = ischema.sys_objects + sys_schemas = ischema.sys_schemas + + s = ( + sql.select(sys_objects.c.name) + .select_from(sys_objects) + .join( + sys_schemas, + onclause=sys_objects.c.schema_id == sys_schemas.c.schema_id, + ) + .where( + sys_schemas.c.name == sql.bindparam("schema"), + sys_objects.c.type.in_( + sql.bindparam("type_filter", expanding=True) + ), + ) + ) + if add_filter_names: + s = s.where( + sys_objects.c.name.in_( + sql.bindparam("filter_names", expanding=True) + ) + ) + + return s + def _partition_filter_names( self, connection, owner, filter_names, scope, kind ): @@ -3914,29 +3961,20 @@ index_info AS ( type_filter = [] if ObjectKind.TABLE in kind: - type_filter.append("'U'") + type_filter.append("U") if ObjectKind.VIEW in kind: - type_filter.append("'V'") + type_filter.append("V") # SQL Server does not support materialized views, so ignore them if not type_filter: return (regular_names, [], temp_names) - query = ( - "SELECT o.name " - "FROM sys.objects o " - "JOIN sys.schemas s ON o.schema_id = s.schema_id " - "WHERE s.name = :owner " - "AND o.type IN (%s)" % ", ".join(type_filter) - ) - params = [sql.bindparam("owner", owner, ischema.CoerceUnicode())] - if regular_names: - query += " AND o.name IN :filter_names" - params.append( - sql.bindparam("filter_names", regular_names, expanding=True) - ) + has_regular_names = bool(regular_names) + query = self._partition_filter_names_query(has_regular_names) + params = {"schema": owner, "type_filter": type_filter} + if has_regular_names: + params["filter_names"] = regular_names - rp = connection.execute(sql.text(query).bindparams(*params)) - multi_object_names = [row[0] for row in rp] + multi_object_names = connection.scalars(query, params).all() return (regular_names, multi_object_names, temp_names) @@ -4305,9 +4343,13 @@ index_info AS ( # return empty foreign_keys for temp tables that exist. We # preserve that here by checking existence and returning the # default (empty) reflection for each temp that is reachable. + has_temp_tables = self._internal_has_multi_table( + connection, temp_names, owner="dbo", schema=schema, **kw + ) for name in temp_names: - if self._internal_has_table(connection, name, owner="dbo"): - final[(schema, name)] = ReflectionDefaults.foreign_keys() + key = (schema, name) + if has_temp_tables.get(key): + final[key] = ReflectionDefaults.foreign_keys() return final.items() diff --git a/lib/sqlalchemy/dialects/oracle/base.py b/lib/sqlalchemy/dialects/oracle/base.py index fbe0c6c90a..4013d34f80 100644 --- a/lib/sqlalchemy/dialects/oracle/base.py +++ b/lib/sqlalchemy/dialects/oracle/base.py @@ -2311,7 +2311,7 @@ class OracleDialect(default._BackendsMultiReflection, default.DefaultDialect): ) @util.memoized_property - def _has_table_query(self): + def _has_multi_table_query(self): # materialized views are returned by all_tables tables = ( select( @@ -2328,33 +2328,35 @@ class OracleDialect(default._BackendsMultiReflection, default.DefaultDialect): ) query = select(tables.c.table_name).where( - tables.c.table_name == bindparam("table_name"), + tables.c.table_name.in_(bindparam("table_names")), tables.c.owner == bindparam("owner"), ) return query - @reflection.cache - def has_table( - self, connection, table_name, schema=None, dblink=None, **kw + def has_multi_table( + self, connection, table_names, schema=None, dblink=None, **kw ): """Supported kw arguments are: ``dblink`` to reflect via a db link.""" - self._ensure_has_table_connection(connection) - - if not schema: - schema = self.default_schema_name + owner = schema or self.default_schema_name + db_tns = [self.denormalize_name(tn) for tn in table_names] params = { - "table_name": self.denormalize_name(table_name), - "owner": self.denormalize_schema_name(schema), + "table_names": db_tns, + "owner": self.denormalize_schema_name(owner), } cursor = self._execute_reflection( connection, - self._has_table_query, + self._has_multi_table_query, dblink, returns_long=False, params=params, ) - return bool(cursor.scalar()) + existing = set(cursor.scalars().all()) + retval = { + (schema, table): db_tn in existing + for table, db_tn in zip(table_names, db_tns) + } + return retval.items() @reflection.cache def has_sequence( diff --git a/lib/sqlalchemy/dialects/postgresql/base.py b/lib/sqlalchemy/dialects/postgresql/base.py index 88654295d6..74a2f19185 100644 --- a/lib/sqlalchemy/dialects/postgresql/base.py +++ b/lib/sqlalchemy/dialects/postgresql/base.py @@ -3911,9 +3911,11 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): return pg_class_table.c.relkind == sql.any_(_array.array(relkinds)) @lru_cache() - def _has_table_query(self, schema): + def _has_multi_table_query(self, schema): query = select(pg_catalog.pg_class.c.relname).where( - pg_catalog.pg_class.c.relname == bindparam("table_name"), + pg_catalog.pg_class.c.relname.in_( + bindparam("table_names", expanding=True) + ), self._pg_class_relkind_condition( pg_catalog.RELKINDS_ALL_TABLE_LIKE ), @@ -3924,9 +3926,18 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect): @reflection.cache def has_table(self, connection, table_name, schema=None, **kw): + # NOTE: it's not worth calling into the multi table since the query + # is compatible also with this single case. self._ensure_has_table_connection(connection) - query = self._has_table_query(schema) - return bool(connection.scalar(query, {"table_name": table_name})) + query = self._has_multi_table_query(schema) + return bool(connection.scalar(query, {"table_names": [table_name]})) + + def has_multi_table(self, connection, table_names, schema=None, **kw): + query = self._has_multi_table_query(schema) + params = {"table_names": table_names} + existing = set(connection.scalars(query, params).all()) + retval = {(schema, table): table in existing for table in table_names} + return retval.items() @reflection.cache def has_sequence(self, connection, sequence_name, schema=None, **kw): diff --git a/lib/sqlalchemy/engine/default.py b/lib/sqlalchemy/engine/default.py index 05dd336d47..b3911b485a 100644 --- a/lib/sqlalchemy/engine/default.py +++ b/lib/sqlalchemy/engine/default.py @@ -27,6 +27,7 @@ from typing import Callable from typing import cast from typing import Dict from typing import Final +from typing import Iterable from typing import List from typing import Literal from typing import Mapping @@ -88,6 +89,7 @@ if typing.TYPE_CHECKING: from .interfaces import DBAPIModule from .interfaces import DBAPIType from .interfaces import IsolationLevel + from .interfaces import TableKey from .row import Row from .url import URL from ..event import _ListenerFnType @@ -128,6 +130,26 @@ class _BackendsMultiReflection(Dialect): (PostgreSQL, Oracle, MSSQL). """ + @reflection.cache + def has_table( + self, + connection: Connection, + table_name: str, + schema: Optional[str] = None, + **kw: Any, + ) -> bool: + # NOTE: assume it's a subclass of DefaultDialect + self._ensure_has_table_connection(connection) # type: ignore + multi_res = self.has_multi_table( + connection, + table_names=[table_name], + schema=schema, + **kw, + ) + # has_multi_table returns all the input table names so it's not + # possible for the key to be missing + return dict(multi_res)[(schema, table_name)] + def _value_or_raise(self, data, table, schema): try: return dict(data)[(schema, table)] @@ -1273,6 +1295,17 @@ class DefaultDialect(Dialect): except exc.NoSuchTableError: pass + def has_multi_table( + self, + connection: Connection, + table_names: Sequence[str], + schema: Optional[str] = None, + **kw: Any, + ) -> Iterable[tuple[TableKey, bool]]: + for table_name in table_names: + exist = self.has_table(connection, table_name, schema=schema, **kw) + yield (schema, table_name), exist + def get_multi_table_options(self, connection, **kw): return self._default_multi_reflect( self.get_table_options, connection, **kw diff --git a/lib/sqlalchemy/engine/interfaces.py b/lib/sqlalchemy/engine/interfaces.py index 986d39d40f..0109e3b4f5 100644 --- a/lib/sqlalchemy/engine/interfaces.py +++ b/lib/sqlalchemy/engine/interfaces.py @@ -1913,6 +1913,30 @@ class Dialect(EventTarget): raise NotImplementedError() + def has_multi_table( + self, + connection: Connection, + table_names: Sequence[str], + schema: Optional[str] = None, + **kw: Any, + ) -> Iterable[Tuple[TableKey, bool]]: + """For internal dialect use, check the existence of a particular list + of tables or views in the database. + + This is an internal dialect method. Applications should use + :meth:`.Inspector.has_multi_table`. + + .. note:: The :class:`_engine.DefaultDialect` provides a default + implementation that will call the single table method for + each table name provided. Dialects that want to support a faster + implementation should implement this method. + + .. versionadded:: 2.1 + + """ + + raise NotImplementedError() + def has_index( self, connection: Connection, diff --git a/lib/sqlalchemy/engine/reflection.py b/lib/sqlalchemy/engine/reflection.py index 48418f2fe9..475be4f072 100644 --- a/lib/sqlalchemy/engine/reflection.py +++ b/lib/sqlalchemy/engine/reflection.py @@ -188,6 +188,13 @@ class Inspector(inspection.Inspectable["Inspector"]): consistent interface as well as caching support for previously fetched metadata. + The caching behavior is dialect-specific: as a general rule single table + reflection methods should cache their result, while multi-table reflection + methods should not, but behavior may vary. + Following DDL operations on the database, it is recommended to either + create a new :class:`_reflection.Inspector` instance or to clear the cache + of existing instances using the :meth:`.Inspector.clear_cache` method. + A :class:`_reflection.Inspector` object is usually created via the :func:`_sa.inspect` function, which may be passed an :class:`_engine.Engine` @@ -268,7 +275,7 @@ class Inspector(inspection.Inspectable["Inspector"]): self.info_cache = {} def clear_cache(self) -> None: - """reset the cache for this :class:`.Inspector`. + """Reset the cache for this :class:`.Inspector`. Inspection methods that have data cached will emit SQL queries when next called to get new data. @@ -441,6 +448,33 @@ class Inspector(inspection.Inspectable["Inspector"]): conn, table_name, schema, info_cache=self.info_cache, **kw ) + def has_multi_table( + self, + table_names: Sequence[str], + schema: Optional[str] = None, + **kw: Any, + ) -> Dict[TableKey, bool]: + r"""Return a dict indicating for each table name whether the backend + has a table, view, or temporary table with the given name. + + :param table_names: sequence of table names to check. + :param schema: schema name to query, if not the default schema. + :param \**kw: Additional keyword argument to pass to the dialect + specific implementation. See the documentation of the dialect + in use for more information. + + .. versionadded:: 2.1 + + .. seealso:: :meth:`Inspector.has_table` + + """ + with self._operation_context() as conn: + return dict( + self.dialect.has_multi_table( + conn, table_names, schema, info_cache=self.info_cache, **kw + ) + ) + def has_sequence( self, sequence_name: str, schema: Optional[str] = None, **kw: Any ) -> bool: diff --git a/lib/sqlalchemy/sql/ddl.py b/lib/sqlalchemy/sql/ddl.py index be40cdf877..1352681574 100644 --- a/lib/sqlalchemy/sql/ddl.py +++ b/lib/sqlalchemy/sql/ddl.py @@ -1344,7 +1344,52 @@ class CheckFirst(Flag): return super()._missing_(value) -class SchemaGenerator(InvokeCreateDDLBase): +class _SchemaTableReflector: + _effective_tables: typing_Sequence[Table] | None = None + _has_tables: dict[tuple[Optional[str], str], bool] + + def __init__( + self, + dialect: Dialect, + connection: Connection, + tables: typing_Sequence[Table] | None, + ): + self.dialect = dialect + self.connection = connection + self.tables = self._effective_tables = tables + self._has_tables = {} + + def _update_effective_tables(self, metadata): + if self.tables is not None: + self._effective_tables = self.tables + else: + self._effective_tables = list(metadata.tables.values()) + return self._effective_tables + + def _has_table(self, table: Table, schema: str | None) -> bool: + res = self._has_tables.get((schema, table.name)) + if res is not None: + return res + if self._effective_tables is None: + to_check = [table.name] + else: + is_view = table.is_view + to_check = [ + t.name + for t in self._effective_tables + if t.is_view == is_view + and self.connection.schema_for_object(t) == schema + ] + assert table.name in to_check + self._has_tables.update( + self.dialect.has_multi_table( + self.connection, to_check, schema=schema + ) + ) + return self._has_tables[(schema, table.name)] + + +class SchemaGenerator(InvokeCreateDDLBase, _SchemaTableReflector): def __init__( self, dialect, @@ -1353,11 +1398,10 @@ class SchemaGenerator(InvokeCreateDDLBase): tables=None, **kwargs, ): - super().__init__(connection, **kwargs) + InvokeCreateDDLBase.__init__(self, connection, **kwargs) + _SchemaTableReflector.__init__(self, dialect, connection, tables) self.checkfirst = CheckFirst(checkfirst) - self.tables = tables self.preparer = dialect.identifier_preparer - self.dialect = dialect self.memo = {} def _can_create_table(self, table): @@ -1369,11 +1413,8 @@ class SchemaGenerator(InvokeCreateDDLBase): bool_to_check = ( CheckFirst.TABLES if not table.is_view else CheckFirst.VIEWS ) - return ( - not self.checkfirst & bool_to_check - or not self.dialect.has_table( - self.connection, table.name, schema=effective_schema - ) + return not self.checkfirst & bool_to_check or not self._has_table( + table, effective_schema ) def _can_create_index(self, index): @@ -1404,10 +1445,7 @@ class SchemaGenerator(InvokeCreateDDLBase): ) def visit_metadata(self, metadata): - if self.tables is not None: - tables = self.tables - else: - tables = list(metadata.tables.values()) + tables = self._update_effective_tables(metadata) collection = sort_tables_and_constraints( [t for t in tables if self._can_create_table(t)] @@ -1520,7 +1558,7 @@ class SchemaGenerator(InvokeCreateDDLBase): CreateIndex(index)._invoke_with(self.connection) -class SchemaDropper(InvokeDropDDLBase): +class SchemaDropper(InvokeDropDDLBase, _SchemaTableReflector): def __init__( self, dialect, @@ -1529,18 +1567,14 @@ class SchemaDropper(InvokeDropDDLBase): tables=None, **kwargs, ): - super().__init__(connection, **kwargs) + InvokeDropDDLBase.__init__(self, connection, **kwargs) + _SchemaTableReflector.__init__(self, dialect, connection, tables) self.checkfirst = CheckFirst(checkfirst) - self.tables = tables self.preparer = dialect.identifier_preparer - self.dialect = dialect self.memo = {} def visit_metadata(self, metadata): - if self.tables is not None: - tables = self.tables - else: - tables = list(metadata.tables.values()) + tables = self._update_effective_tables(metadata) try: unsorted_tables = [t for t in tables if self._can_drop_table(t)] @@ -1623,8 +1657,8 @@ class SchemaDropper(InvokeDropDDLBase): CheckFirst.TABLES if not table.is_view else CheckFirst.VIEWS ) - return not self.checkfirst & bool_to_check or self.dialect.has_table( - self.connection, table.name, schema=effective_schema + return not self.checkfirst & bool_to_check or self._has_table( + table, effective_schema ) def _can_drop_index(self, index): diff --git a/lib/sqlalchemy/testing/suite/test_reflection.py b/lib/sqlalchemy/testing/suite/test_reflection.py index 3cb271c7bd..7b8ea6d2c7 100644 --- a/lib/sqlalchemy/testing/suite/test_reflection.py +++ b/lib/sqlalchemy/testing/suite/test_reflection.py @@ -156,6 +156,20 @@ class HasTableTest(OneConnectionTablesTest): is_false(config.db.dialect.has_table(conn, "test_table_s")) is_false(config.db.dialect.has_table(conn, "nonexistent_table")) + def test_has_multi_table(self): + with config.db.begin() as conn: + res = dict( + config.db.dialect.has_multi_table( + conn, ["test_table", "test_table_s", "nonexistent_table"] + ) + ) + exp = { + (None, "test_table"): True, + (None, "test_table_s"): False, + (None, "nonexistent_table"): False, + } + eq_(res, exp) + def test_has_table_cache(self, metadata): insp = inspect(config.db) is_true(insp.has_table("test_table")) @@ -188,6 +202,24 @@ class HasTableTest(OneConnectionTablesTest): ) ) + @testing.requires.schemas + def test_has_multi_table_schema(self): + + with config.db.begin() as conn: + res = dict( + config.db.dialect.has_multi_table( + conn, + ["test_table", "test_table_s", "nonexistent_table"], + schema=config.test_schema, + ) + ) + exp = { + (config.test_schema, "test_table"): False, + (config.test_schema, "test_table_s"): True, + (config.test_schema, "nonexistent_table"): False, + } + eq_(res, exp) + @testing.requires.schemas def test_has_table_nonexistent_schema(self): with config.db.begin() as conn: @@ -196,6 +228,14 @@ class HasTableTest(OneConnectionTablesTest): conn, "test_table", schema="nonexistent_schema" ) ) + eq_( + dict( + config.db.dialect.has_multi_table( + conn, ["test_table"], schema="nonexistent_schema" + ) + ), + {("nonexistent_schema", "test_table"): False}, + ) @testing.requires.views def test_has_table_view(self, connection): diff --git a/test/perf/many_table_reflection.py b/test/perf/many_table_reflection.py index 4fa768a74e..405ef23be1 100644 --- a/test/perf/many_table_reflection.py +++ b/test/perf/many_table_reflection.py @@ -194,6 +194,7 @@ def _single_test( table_names, timing, mode, + multi_needs_table_names=False, ): single = None if "single" in mode: @@ -220,7 +221,10 @@ def _single_test( insp = sa.inspect(engine) multi_fn = getattr(Inspector, multi_fn_name) with timing(multi_fn.__name__): - multi = multi_fn(insp, schema=schema_name) + if multi_needs_table_names: + multi = multi_fn(insp, table_names, schema=schema_name) + else: + multi = multi_fn(insp, schema=schema_name) return (multi, single) @@ -389,6 +393,22 @@ def reflect_unique_constraints( verify_dict(multi, single) +@define_test +def has_tables(engine, schema_name, table_names, timing, mode, ignore_diff): + multi, single = _single_test( + "has_table", + "has_multi_table", + engine, + schema_name, + table_names, + timing, + mode, + multi_needs_table_names=True, + ) + if not ignore_diff: + verify_dict(multi, single, str_compare=True) + + def _apply_events(engine): queries = defaultdict(list) diff --git a/test/sql/test_ddlemit.py b/test/sql/test_ddlemit.py index 703d866320..1b60741a89 100644 --- a/test/sql/test_ddlemit.py +++ b/test/sql/test_ddlemit.py @@ -22,6 +22,9 @@ from sqlalchemy.testing import is_ class EmitDDLTest(fixtures.TestBase): def _mock_connection(self, item_exists): + def has_many_item(connection, names, schema): + return [((schema, name), item_exists(name)) for name in names] + def has_item(connection, name, schema): return item_exists(name) @@ -31,12 +34,13 @@ class EmitDDLTest(fixtures.TestBase): return Mock( dialect=Mock( supports_sequences=True, - has_table=Mock(side_effect=has_item), + has_multi_table=Mock(side_effect=has_many_item), has_sequence=Mock(side_effect=has_item), has_index=Mock(side_effect=has_index), supports_comments=True, inline_comments=False, ), + schema_for_object=lambda t: t.schema, _schema_translate_map=None, ) @@ -71,17 +75,28 @@ class EmitDDLTest(fixtures.TestBase): Table("t%d" % i, m, Column("x", Integer)) for i in range(1, 6) ) - def _table_and_view_fixture(self): + def _table_and_view_fixture(self, multi_schema=False): m = MetaData() tables = [ - Table("t%d" % i, m, Column("x", Integer)) for i in range(1, 4) + Table( + "t%d" % i, + m, + Column("x", Integer), + schema="s1" if multi_schema and i > 1 else None, + ) + for i in range(1, 4) ] t1, t2, t3 = tables views = [ CreateView(select(t1), "v1", metadata=m).table, - CreateView(select(t3), "v2", metadata=m).table, + CreateView( + select(t3), + "v2", + metadata=m, + schema="s1" if multi_schema else None, + ).table, ] return (m,) + tuple(tables) + tuple(views) @@ -456,6 +471,22 @@ class EmitDDLTest(fixtures.TestBase): self._assert_drop_tables([t1, t2, t3, v1, v2], generator, m, False) + def test_create_metadata_wviews_many_schema(self): + m, t1, t2, t3, v1, v2 = self._table_and_view_fixture(multi_schema=True) + generator = self._mock_create_fixture( + True, None, item_exists=lambda t: t not in ("t2", "v2") + ) + + self._assert_create_tables([t2, v2], generator, m, True) + + def test_drop_metadata_wviews_many_schema(self): + m, t1, t2, t3, v1, v2 = self._table_and_view_fixture(multi_schema=True) + generator = self._mock_drop_fixture( + True, None, item_exists=lambda t: t in ("t2", "v2") + ) + + self._assert_drop_tables([t2, v2], generator, m, True) + def test_create_metadata_auto_alter_fk(self): m, t1, t2 = self._use_alter_fixture_one() generator = self._mock_create_fixture(False, [t1, t2]) @@ -478,6 +509,27 @@ class EmitDDLTest(fixtures.TestBase): m, ) + def _check_mock(self, generator, tables, views): + if tables or views: + tbs = {} + for t in tables: + tbs.setdefault(t.schema, []).append(t.name) + vbs = {} + for t in views: + vbs.setdefault(t.schema, []).append(t.name) + + calls = [ + mock.call(mock.ANY, names, schema=schema) + for data in (tbs, vbs) + for schema, names in data.items() + ] + eq_(generator.dialect.has_multi_table.mock_calls, calls) + else: + eq_( + generator.dialect.has_multi_table.mock_calls, + [], + ) + def _assert_create_tables(self, elements, generator, argument, checkfirst): self._assert_ddl( (schema.CreateTable, schema.CreateView), @@ -486,7 +538,7 @@ class EmitDDLTest(fixtures.TestBase): argument, ) - tables = [] + tables, views = [], [] if CheckFirst(checkfirst) & CheckFirst.TABLES: if generator.tables is not None: tables.extend([t for t in generator.tables if not t.is_view]) @@ -499,34 +551,22 @@ class EmitDDLTest(fixtures.TestBase): if CheckFirst(checkfirst) & CheckFirst.VIEWS: if generator.tables is not None: - tables.extend([t for t in generator.tables if t.is_view]) + views.extend([t for t in generator.tables if t.is_view]) elif isinstance(argument, MetaData): - tables.extend( + views.extend( [t for t in argument.tables.values() if t.is_view] ) else: assert False, "don't know what views we are checking" - if tables: - eq_( - generator.dialect.has_table.mock_calls, - [ - mock.call(mock.ANY, tablename, schema=mock.ANY) - for tablename in [t.name for t in tables] - ], - ) - else: - eq_( - generator.dialect.has_index.mock_calls, - [], - ) + self._check_mock(generator, tables, views) def _assert_drop_tables(self, elements, generator, argument, checkfirst): self._assert_ddl( (schema.DropTable, schema.DropView), elements, generator, argument ) - tables = [] + tables, views = [], [] if CheckFirst(checkfirst) & CheckFirst.TABLES: if generator.tables is not None: tables.extend([t for t in generator.tables if not t.is_view]) @@ -539,27 +579,14 @@ class EmitDDLTest(fixtures.TestBase): if CheckFirst(checkfirst) & CheckFirst.VIEWS: if generator.tables is not None: - tables.extend([t for t in generator.tables if t.is_view]) + views.extend([t for t in generator.tables if t.is_view]) elif isinstance(argument, MetaData): - tables.extend( + views.extend( [t for t in argument.tables.values() if t.is_view] ) else: assert False, "don't know what views we are checking" - - if tables: - eq_( - generator.dialect.has_table.mock_calls, - [ - mock.call(mock.ANY, tablename, schema=mock.ANY) - for tablename in [t.name for t in tables] - ], - ) - else: - eq_( - generator.dialect.has_index.mock_calls, - [], - ) + self._check_mock(generator, tables, views) def _assert_create(self, elements, generator, argument): self._assert_ddl(