fixtures.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. from __future__ import annotations
  2. import configparser
  3. from contextlib import contextmanager
  4. import io
  5. import re
  6. from typing import Any
  7. from typing import Dict
  8. from sqlalchemy import Column
  9. from sqlalchemy import inspect
  10. from sqlalchemy import MetaData
  11. from sqlalchemy import String
  12. from sqlalchemy import Table
  13. from sqlalchemy import testing
  14. from sqlalchemy import text
  15. from sqlalchemy.testing import config
  16. from sqlalchemy.testing import mock
  17. from sqlalchemy.testing.assertions import eq_
  18. from sqlalchemy.testing.fixtures import TablesTest as SQLAlchemyTablesTest
  19. from sqlalchemy.testing.fixtures import TestBase as SQLAlchemyTestBase
  20. import alembic
  21. from .assertions import _get_dialect
  22. from ..environment import EnvironmentContext
  23. from ..migration import MigrationContext
  24. from ..operations import Operations
  25. from ..util import sqla_compat
  26. from ..util.sqla_compat import create_mock_engine
  27. from ..util.sqla_compat import sqla_14
  28. from ..util.sqla_compat import sqla_2
  29. testing_config = configparser.ConfigParser()
  30. testing_config.read(["test.cfg"])
  31. class TestBase(SQLAlchemyTestBase):
  32. is_sqlalchemy_future = sqla_2
  33. @testing.fixture()
  34. def ops_context(self, migration_context):
  35. with migration_context.begin_transaction(_per_migration=True):
  36. yield Operations(migration_context)
  37. @testing.fixture
  38. def migration_context(self, connection):
  39. return MigrationContext.configure(
  40. connection, opts=dict(transaction_per_migration=True)
  41. )
  42. @testing.fixture
  43. def connection(self):
  44. with config.db.connect() as conn:
  45. yield conn
  46. class TablesTest(TestBase, SQLAlchemyTablesTest):
  47. pass
  48. if sqla_14:
  49. from sqlalchemy.testing.fixtures import FutureEngineMixin
  50. else:
  51. class FutureEngineMixin: # type:ignore[no-redef]
  52. __requires__ = ("sqlalchemy_14",)
  53. FutureEngineMixin.is_sqlalchemy_future = True
  54. def capture_db(dialect="postgresql://"):
  55. buf = []
  56. def dump(sql, *multiparams, **params):
  57. buf.append(str(sql.compile(dialect=engine.dialect)))
  58. engine = create_mock_engine(dialect, dump)
  59. return engine, buf
  60. _engs: Dict[Any, Any] = {}
  61. @contextmanager
  62. def capture_context_buffer(**kw):
  63. if kw.pop("bytes_io", False):
  64. buf = io.BytesIO()
  65. else:
  66. buf = io.StringIO()
  67. kw.update({"dialect_name": "sqlite", "output_buffer": buf})
  68. conf = EnvironmentContext.configure
  69. def configure(*arg, **opt):
  70. opt.update(**kw)
  71. return conf(*arg, **opt)
  72. with mock.patch.object(EnvironmentContext, "configure", configure):
  73. yield buf
  74. @contextmanager
  75. def capture_engine_context_buffer(**kw):
  76. from .env import _sqlite_file_db
  77. from sqlalchemy import event
  78. buf = io.StringIO()
  79. eng = _sqlite_file_db()
  80. conn = eng.connect()
  81. @event.listens_for(conn, "before_cursor_execute")
  82. def bce(conn, cursor, statement, parameters, context, executemany):
  83. buf.write(statement + "\n")
  84. kw.update({"connection": conn})
  85. conf = EnvironmentContext.configure
  86. def configure(*arg, **opt):
  87. opt.update(**kw)
  88. return conf(*arg, **opt)
  89. with mock.patch.object(EnvironmentContext, "configure", configure):
  90. yield buf
  91. def op_fixture(
  92. dialect="default",
  93. as_sql=False,
  94. naming_convention=None,
  95. literal_binds=False,
  96. native_boolean=None,
  97. ):
  98. opts = {}
  99. if naming_convention:
  100. opts["target_metadata"] = MetaData(naming_convention=naming_convention)
  101. class buffer_:
  102. def __init__(self):
  103. self.lines = []
  104. def write(self, msg):
  105. msg = msg.strip()
  106. msg = re.sub(r"[\n\t]", "", msg)
  107. if as_sql:
  108. # the impl produces soft tabs,
  109. # so search for blocks of 4 spaces
  110. msg = re.sub(r" ", "", msg)
  111. msg = re.sub(r"\;\n*$", "", msg)
  112. self.lines.append(msg)
  113. def flush(self):
  114. pass
  115. buf = buffer_()
  116. class ctx(MigrationContext):
  117. def get_buf(self):
  118. return buf
  119. def clear_assertions(self):
  120. buf.lines[:] = []
  121. def assert_(self, *sql):
  122. # TODO: make this more flexible about
  123. # whitespace and such
  124. eq_(buf.lines, [re.sub(r"[\n\t]", "", s) for s in sql])
  125. def assert_contains(self, sql):
  126. for stmt in buf.lines:
  127. if re.sub(r"[\n\t]", "", sql) in stmt:
  128. return
  129. else:
  130. assert False, "Could not locate fragment %r in %r" % (
  131. sql,
  132. buf.lines,
  133. )
  134. if as_sql:
  135. opts["as_sql"] = as_sql
  136. if literal_binds:
  137. opts["literal_binds"] = literal_binds
  138. if not sqla_14 and dialect == "mariadb":
  139. ctx_dialect = _get_dialect("mysql")
  140. ctx_dialect.server_version_info = (10, 4, 0, "MariaDB")
  141. else:
  142. ctx_dialect = _get_dialect(dialect)
  143. if native_boolean is not None:
  144. ctx_dialect.supports_native_boolean = native_boolean
  145. # this is new as of SQLAlchemy 1.2.7 and is used by SQL Server,
  146. # which breaks assumptions in the alembic test suite
  147. ctx_dialect.non_native_boolean_check_constraint = True
  148. if not as_sql:
  149. def execute(stmt, *multiparam, **param):
  150. if isinstance(stmt, str):
  151. stmt = text(stmt)
  152. assert stmt.supports_execution
  153. sql = str(stmt.compile(dialect=ctx_dialect))
  154. buf.write(sql)
  155. connection = mock.Mock(dialect=ctx_dialect, execute=execute)
  156. else:
  157. opts["output_buffer"] = buf
  158. connection = None
  159. context = ctx(ctx_dialect, connection, opts)
  160. alembic.op._proxy = Operations(context)
  161. return context
  162. class AlterColRoundTripFixture:
  163. # since these tests are about syntax, use more recent SQLAlchemy as some of
  164. # the type / server default compare logic might not work on older
  165. # SQLAlchemy versions as seems to be the case for SQLAlchemy 1.1 on Oracle
  166. __requires__ = ("alter_column",)
  167. def setUp(self):
  168. self.conn = config.db.connect()
  169. self.ctx = MigrationContext.configure(self.conn)
  170. self.op = Operations(self.ctx)
  171. self.metadata = MetaData()
  172. def _compare_type(self, t1, t2):
  173. c1 = Column("q", t1)
  174. c2 = Column("q", t2)
  175. assert not self.ctx.impl.compare_type(
  176. c1, c2
  177. ), "Type objects %r and %r didn't compare as equivalent" % (t1, t2)
  178. def _compare_server_default(self, t1, s1, t2, s2):
  179. c1 = Column("q", t1, server_default=s1)
  180. c2 = Column("q", t2, server_default=s2)
  181. assert not self.ctx.impl.compare_server_default(
  182. c1, c2, s2, s1
  183. ), "server defaults %r and %r didn't compare as equivalent" % (s1, s2)
  184. def tearDown(self):
  185. sqla_compat._safe_rollback_connection_transaction(self.conn)
  186. with self.conn.begin():
  187. self.metadata.drop_all(self.conn)
  188. self.conn.close()
  189. def _run_alter_col(self, from_, to_, compare=None):
  190. column = Column(
  191. from_.get("name", "colname"),
  192. from_.get("type", String(10)),
  193. nullable=from_.get("nullable", True),
  194. server_default=from_.get("server_default", None),
  195. # comment=from_.get("comment", None)
  196. )
  197. t = Table("x", self.metadata, column)
  198. with sqla_compat._ensure_scope_for_ddl(self.conn):
  199. t.create(self.conn)
  200. insp = inspect(self.conn)
  201. old_col = insp.get_columns("x")[0]
  202. # TODO: conditional comment support
  203. self.op.alter_column(
  204. "x",
  205. column.name,
  206. existing_type=column.type,
  207. existing_server_default=column.server_default
  208. if column.server_default is not None
  209. else False,
  210. existing_nullable=True if column.nullable else False,
  211. # existing_comment=column.comment,
  212. nullable=to_.get("nullable", None),
  213. # modify_comment=False,
  214. server_default=to_.get("server_default", False),
  215. new_column_name=to_.get("name", None),
  216. type_=to_.get("type", None),
  217. )
  218. insp = inspect(self.conn)
  219. new_col = insp.get_columns("x")[0]
  220. if compare is None:
  221. compare = to_
  222. eq_(
  223. new_col["name"],
  224. compare["name"] if "name" in compare else column.name,
  225. )
  226. self._compare_type(
  227. new_col["type"], compare.get("type", old_col["type"])
  228. )
  229. eq_(new_col["nullable"], compare.get("nullable", column.nullable))
  230. self._compare_server_default(
  231. new_col["type"],
  232. new_col.get("default", None),
  233. compare.get("type", old_col["type"]),
  234. compare["server_default"].text
  235. if "server_default" in compare
  236. else column.server_default.arg.text
  237. if column.server_default is not None
  238. else None,
  239. )