From: Mike Bayer Date: Wed, 17 Sep 2025 19:31:11 +0000 (-0400) Subject: mssql-python X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=d7d970222e47da84539fc567df635eb650251c57;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git mssql-python Added support for the ``mssql-python`` driver, Microsoft's official Python driver for SQL Server. Fixes: #12869 Change-Id: I9ce0fef1dd1105c20833cc6a46f24ac9580c4b39 --- diff --git a/doc/build/changelog/migration_21.rst b/doc/build/changelog/migration_21.rst index 193ef56690..c3094618c7 100644 --- a/doc/build/changelog/migration_21.rst +++ b/doc/build/changelog/migration_21.rst @@ -1675,6 +1675,41 @@ required if using the connection string directly with ``pyodbc.connect()``). :ticket:`11250` +.. _change_12869: + +Support for mssql-python driver +-------------------------------- + +SQLAlchemy 2.1 adds support for the ``mssql-python`` driver, Microsoft's +official Python driver for SQL Server. This driver represents a modern +alternative to the widely-used ``pyodbc`` driver, with native support for +several SQL Server-specific features. + +The ``mssql-python`` driver can be used by specifying ``mssql+mssqlpython`` +in the connection URL:: + + from sqlalchemy import create_engine + + # Basic connection + engine = create_engine("mssql+mssqlpython://user:password@hostname/database") + + # With Windows Authentication + engine = create_engine( + "mssql+mssqlpython://hostname/database?authentication=ActiveDirectoryIntegrated" + ) + +The ``mssql-python`` driver is available from PyPI: + +.. sourcecode:: text + + pip install mssql-python + +.. seealso:: + + :ref:`mssql_python` - Documentation for the mssql-python dialect + +:ticket:`12869` + Oracle Database =============== diff --git a/doc/build/changelog/unreleased_21/12869.rst b/doc/build/changelog/unreleased_21/12869.rst new file mode 100644 index 0000000000..173ec53d49 --- /dev/null +++ b/doc/build/changelog/unreleased_21/12869.rst @@ -0,0 +1,11 @@ +.. change:: + :tags: feature, mssql + :tickets: 12869 + + Added support for the ``mssql-python`` driver, Microsoft's official Python + driver for SQL Server. + + .. seealso:: + + :ref:`change_12869` + diff --git a/doc/build/dialects/mssql.rst b/doc/build/dialects/mssql.rst index b4ea496905..5d7d35395e 100644 --- a/doc/build/dialects/mssql.rst +++ b/doc/build/dialects/mssql.rst @@ -161,6 +161,12 @@ PyODBC ------ .. automodule:: sqlalchemy.dialects.mssql.pyodbc +.. _mssql_python: + +mssql-python +------------ +.. automodule:: sqlalchemy.dialects.mssql.mssqlpython + pymssql ------- .. automodule:: sqlalchemy.dialects.mssql.pymssql diff --git a/lib/sqlalchemy/dialects/mssql/__init__.py b/lib/sqlalchemy/dialects/mssql/__init__.py index 68d1d7c744..3ad714921c 100644 --- a/lib/sqlalchemy/dialects/mssql/__init__.py +++ b/lib/sqlalchemy/dialects/mssql/__init__.py @@ -8,6 +8,7 @@ from . import aioodbc # noqa from . import base # noqa +from . import mssqlpython # noqa from . import pymssql # noqa from . import pyodbc # noqa from .base import BIGINT diff --git a/lib/sqlalchemy/dialects/mssql/aioodbc.py b/lib/sqlalchemy/dialects/mssql/aioodbc.py index e4d5b2d221..1139f37f79 100644 --- a/lib/sqlalchemy/dialects/mssql/aioodbc.py +++ b/lib/sqlalchemy/dialects/mssql/aioodbc.py @@ -42,12 +42,12 @@ styles are otherwise equivalent to those documented in the pyodbc section:: from __future__ import annotations +from .base import MSExecutionContext from .pyodbc import MSDialect_pyodbc -from .pyodbc import MSExecutionContext_pyodbc from ...connectors.aioodbc import aiodbcConnector -class MSExecutionContext_aioodbc(MSExecutionContext_pyodbc): +class MSExecutionContext_aioodbc(MSExecutionContext): def create_server_side_cursor(self): return self._dbapi_connection.cursor(server_side=True) diff --git a/lib/sqlalchemy/dialects/mssql/base.py b/lib/sqlalchemy/dialects/mssql/base.py index d627127db0..eae3fe7058 100644 --- a/lib/sqlalchemy/dialects/mssql/base.py +++ b/lib/sqlalchemy/dialects/mssql/base.py @@ -1838,6 +1838,7 @@ class MSTypeCompiler(compiler.GenericTypeCompiler): class MSExecutionContext(default.DefaultExecutionContext): + _embedded_scope_identity = False _enable_identity_insert = False _select_lastrowid = False _lastrowid = None @@ -1900,15 +1901,46 @@ class MSExecutionContext(default.DefaultExecutionContext): self, ) + # don't embed the scope_identity select into an + # "INSERT .. DEFAULT VALUES" + if ( + self._select_lastrowid + and self.dialect.scope_identity_must_be_embedded + and self.dialect.use_scope_identity + and len(self.parameters[0]) + ): + self._embedded_scope_identity = True + + self.statement += "; select scope_identity()" + def post_exec(self): - """Disable IDENTITY_INSERT if enabled.""" conn = self.root_connection if self.isinsert or self.isupdate or self.isdelete: self._rowcount = self.cursor.rowcount - if self._select_lastrowid: + # handle INSERT with embedded SELECT SCOPE_IDENTITY() call + if self._embedded_scope_identity: + # Fetch the last inserted id from the manipulated statement + # We may have to skip over a number of result sets with + # no data (due to triggers, etc.) so run up to three times + + row = None + for _ in range(3): + if self.cursor.description: + rows = self.cursor.fetchall() + if rows: + row = rows[0] + break + else: + self.cursor.nextset() + + self._lastrowid = int(row[0]) if row else None + + self.cursor_fetch_strategy = _cursor._NO_CURSOR_DML + + elif self._select_lastrowid: if self.dialect.use_scope_identity: conn._cursor_execute( self.cursor, @@ -1939,6 +1971,7 @@ class MSExecutionContext(default.DefaultExecutionContext): ) if self._enable_identity_insert: + # Disable IDENTITY_INSERT if enabled. if TYPE_CHECKING: assert is_sql_compiler(self.compiled) assert isinstance(self.compiled.compile_state, DMLState) @@ -3026,6 +3059,7 @@ class MSDialect(default.DefaultDialect): supports_default_values = True supports_empty_insert = False favor_returning_over_lastrowid = True + scope_identity_must_be_embedded = False returns_native_bytes = True @@ -3259,6 +3293,7 @@ class MSDialect(default.DefaultDialect): 'attempting to query the "{}" view.'.format(err, view_name) ) from err else: + row = cursor.fetchone() return row[0].upper() finally: diff --git a/lib/sqlalchemy/dialects/mssql/mssqlpython.py b/lib/sqlalchemy/dialects/mssql/mssqlpython.py new file mode 100644 index 0000000000..80a89fdf01 --- /dev/null +++ b/lib/sqlalchemy/dialects/mssql/mssqlpython.py @@ -0,0 +1,220 @@ +# dialects/mssql/mssqlpython.py +# Copyright (C) 2005-2026 the SQLAlchemy authors and contributors +# +# +# This module is part of SQLAlchemy and is released under +# the MIT License: https://www.opensource.org/licenses/mit-license.php +# mypy: ignore-errors + +""" +.. dialect:: mssql+mssqlpython + :name: mssqlpython + :dbapi: mssql-python + :connectstring: mssql+mssqlpython://:@:/ + :url: https://github.com/microsoft/mssql-python + +mssql-python is a driver for Microsoft SQL Server produced by Microsoft. + +.. versionadded:: 2.1.0b2 + + +The driver is generally similar to pyodbc in most aspects as it is based +on the same ODBC framework. + +Connection Strings +------------------ + +Examples of connecting with the mssql-python driver:: + + from sqlalchemy import create_engine + + # Basic connection + engine = create_engine( + "mssql+mssqlpython://user:password@hostname/database" + ) + + # With Windows Authentication + engine = create_engine( + "mssql+mssqlpython://hostname/database?authentication=ActiveDirectoryIntegrated" + ) + +""" # noqa + +from __future__ import annotations + +import re +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING +from typing import Union + +from .base import MSDialect +from .pyodbc import _ms_numeric_pyodbc +from ... import util +from ...sql import sqltypes + +if TYPE_CHECKING: + from ... import pool + from ...engine import interfaces + from ...engine.interfaces import ConnectArgsType + from ...engine.interfaces import DBAPIModule + from ...engine.interfaces import IsolationLevel + from ...engine.interfaces import URL + + +class _MSNumeric_mssqlpython(_ms_numeric_pyodbc, sqltypes.Numeric): + pass + + +class _MSFloat_mssqlpython(_ms_numeric_pyodbc, sqltypes.Float): + pass + + +class MSDialect_mssqlpython(MSDialect): + driver = "mssqlpython" + supports_statement_cache = True + + supports_sane_rowcount_returning = True + supports_sane_multi_rowcount = True + supports_native_uuid = True + scope_identity_must_be_embedded = True + + supports_native_decimal = True + + # used by pyodbc _ms_numeric_pyodbc class + _need_decimal_fix = True + + colspecs = util.update_copy( + MSDialect.colspecs, + { + sqltypes.Numeric: _MSNumeric_mssqlpython, + sqltypes.Float: _MSFloat_mssqlpython, + }, + ) + + def __init__(self, enable_pooling=False, **kw): + super().__init__(**kw) + if not enable_pooling and self.dbapi is not None: + self.loaded_dbapi.pooling(enabled=False) + + @classmethod + def import_dbapi(cls) -> DBAPIModule: + return __import__("mssql_python") + + def create_connect_args(self, url: URL) -> ConnectArgsType: + opts = url.translate_connect_args(username="user") + opts.update(url.query) + + keys = opts + + query = url.query + + connect_args: Dict[str, Any] = {} + connectors: List[str] + + def check_quote(token: str) -> str: + if ";" in str(token) or str(token).startswith("{"): + token = "{%s}" % token.replace("}", "}}") + return token + + keys = {k: check_quote(v) for k, v in keys.items()} + + port = "" + if "port" in keys and "port" not in query: + port = ",%d" % int(keys.pop("port")) + + connectors = [] + + connectors.extend( + [ + "Server=%s%s" % (keys.pop("host", ""), port), + "Database=%s" % keys.pop("database", ""), + ] + ) + + user = keys.pop("user", None) + if user: + connectors.append("UID=%s" % user) + pwd = keys.pop("password", "") + if pwd: + connectors.append("PWD=%s" % pwd) + else: + authentication = keys.pop("authentication", None) + if authentication: + connectors.append("Authentication=%s" % authentication) + + connectors.extend(["%s=%s" % (k, v) for k, v in keys.items()]) + + return ((";".join(connectors),), connect_args) + + def is_disconnect( + self, + e: Exception, + connection: Optional[ + Union[pool.PoolProxiedConnection, interfaces.DBAPIConnection] + ], + cursor: Optional[interfaces.DBAPICursor], + ) -> bool: + if isinstance(e, self.loaded_dbapi.ProgrammingError): + return ( + "The cursor's connection has been closed." in str(e) + or "Attempt to use a closed connection." in str(e) + or "Driver Error: Operation cannot be performed" in str(e) + ) + elif isinstance(e, self.loaded_dbapi.InterfaceError): + return bool(re.search(r"Cannot .* on closed connection", str(e))) + else: + return False + + def _dbapi_version(self) -> interfaces.VersionInfoType: + if not self.dbapi: + return () + return self._parse_dbapi_version(self.dbapi.version) + + def _parse_dbapi_version(self, vers: str) -> interfaces.VersionInfoType: + m = re.match(r"(?:py.*-)?([\d\.]+)(?:-(\w+))?", vers) + if not m: + return () + vers_tuple: interfaces.VersionInfoType = tuple( + [int(x) for x in m.group(1).split(".")] + ) + if m.group(2): + vers_tuple += (m.group(2),) + return vers_tuple + + def _get_server_version_info(self, connection): + vers = connection.exec_driver_sql("select @@version").scalar() + m = re.match(r"Microsoft .*? - (\d+)\.(\d+)\.(\d+)\.(\d+)", vers) + if m: + return tuple(int(x) for x in m.group(1, 2, 3, 4)) + else: + return None + + def get_isolation_level_values( + self, dbapi_connection: interfaces.DBAPIConnection + ) -> List[IsolationLevel]: + return [ + *super().get_isolation_level_values(dbapi_connection), + "AUTOCOMMIT", + ] + + def set_isolation_level( + self, + dbapi_connection: interfaces.DBAPIConnection, + level: IsolationLevel, + ) -> None: + if level == "AUTOCOMMIT": + dbapi_connection.autocommit = True + else: + dbapi_connection.autocommit = False + super().set_isolation_level(dbapi_connection, level) + + def detect_autocommit_setting( + self, dbapi_conn: interfaces.DBAPIConnection + ) -> bool: + return bool(dbapi_conn.autocommit) + + +dialect = MSDialect_mssqlpython diff --git a/lib/sqlalchemy/dialects/mssql/provision.py b/lib/sqlalchemy/dialects/mssql/provision.py index 5b53fd9877..823a13c95d 100644 --- a/lib/sqlalchemy/dialects/mssql/provision.py +++ b/lib/sqlalchemy/dialects/mssql/provision.py @@ -16,6 +16,7 @@ from ...schema import ForeignKeyConstraint from ...schema import MetaData from ...schema import Table from ...testing.provision import create_db +from ...testing.provision import dbapi_error from ...testing.provision import drop_all_schema_objects_pre_tables from ...testing.provision import drop_db from ...testing.provision import generate_driver_url @@ -39,8 +40,10 @@ def generate_driver_url(url, driver, query_str): new_url = url.set(drivername="%s+%s" % (backend, driver)) - if driver not in ("pyodbc", "aioodbc"): + if driver == "pymssql" and url.get_driver_name() != "pymssql": new_url = new_url.set(query="") + elif driver == "mssqlpython" and url.get_driver_name() != "mssqlpython": + new_url = new_url.set(query={"Encrypt": "No"}) if driver == "aioodbc": new_url = new_url.update_query_dict({"MARS_Connection": "Yes"}) @@ -183,3 +186,11 @@ def normalize_sequence(cfg, sequence): if sequence.start is None: sequence.start = 1 return sequence + + +@dbapi_error.for_db("mssql") +def dbapi_error(cfg, cls, message): + if cfg.db.driver == "mssqlpython": + return cls(message, "placeholder for mssqlpython") + else: + return cls(message) diff --git a/lib/sqlalchemy/dialects/mssql/pyodbc.py b/lib/sqlalchemy/dialects/mssql/pyodbc.py index ee1b058f5b..07b9eef60b 100644 --- a/lib/sqlalchemy/dialects/mssql/pyodbc.py +++ b/lib/sqlalchemy/dialects/mssql/pyodbc.py @@ -369,7 +369,6 @@ from .base import _MSUnicodeText from .base import BINARY from .base import DATETIMEOFFSET from .base import MSDialect -from .base import MSExecutionContext from .base import VARBINARY from .json import JSON as _MSJson from .json import JSONIndexType as _MSJsonIndexType @@ -378,7 +377,6 @@ from ... import exc from ... import types as sqltypes from ... import util from ...connectors.pyodbc import PyODBCConnector -from ...engine import cursor as _cursor class _ms_numeric_pyodbc: @@ -560,73 +558,15 @@ class _JSONPathType_pyodbc(_MSJsonPathType): return dbapi.SQL_WVARCHAR -class MSExecutionContext_pyodbc(MSExecutionContext): - _embedded_scope_identity = False - - def pre_exec(self): - """where appropriate, issue "select scope_identity()" in the same - statement. - - Background on why "scope_identity()" is preferable to "@@identity": - https://msdn.microsoft.com/en-us/library/ms190315.aspx - - Background on why we attempt to embed "scope_identity()" into the same - statement as the INSERT: - https://code.google.com/p/pyodbc/wiki/FAQs#How_do_I_retrieve_autogenerated/identity_values? - - """ - - super().pre_exec() - - # don't embed the scope_identity select into an - # "INSERT .. DEFAULT VALUES" - if ( - self._select_lastrowid - and self.dialect.use_scope_identity - and len(self.parameters[0]) - ): - self._embedded_scope_identity = True - - self.statement += "; select scope_identity()" - - def post_exec(self): - if self._embedded_scope_identity: - # Fetch the last inserted id from the manipulated statement - # We may have to skip over a number of result sets with - # no data (due to triggers, etc.) - while True: - try: - # fetchall() ensures the cursor is consumed - # without closing it (FreeTDS particularly) - rows = self.cursor.fetchall() - except self.dialect.dbapi.Error: - # no way around this - nextset() consumes the previous set - # so we need to just keep flipping - self.cursor.nextset() - else: - if not rows: - # async adapter drivers just return None here - self.cursor.nextset() - continue - row = rows[0] - break - - self._lastrowid = int(row[0]) - - self.cursor_fetch_strategy = _cursor._NO_CURSOR_DML - else: - super().post_exec() - - class MSDialect_pyodbc(PyODBCConnector, MSDialect): supports_statement_cache = True + scope_identity_must_be_embedded = True + # note this parameter is no longer used by the ORM or default dialect # see #9414 supports_sane_rowcount_returning = False - execution_ctx_cls = MSExecutionContext_pyodbc - colspecs = util.update_copy( MSDialect.colspecs, { diff --git a/lib/sqlalchemy/engine/base.py b/lib/sqlalchemy/engine/base.py index d1015b4a35..9aaf02f4a2 100644 --- a/lib/sqlalchemy/engine/base.py +++ b/lib/sqlalchemy/engine/base.py @@ -2095,7 +2095,6 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]): sub_params, context, ) - except BaseException as e: self._handle_dbapi_exception( e, diff --git a/lib/sqlalchemy/engine/default.py b/lib/sqlalchemy/engine/default.py index 22253d6a61..d31f8c4f39 100644 --- a/lib/sqlalchemy/engine/default.py +++ b/lib/sqlalchemy/engine/default.py @@ -893,6 +893,7 @@ class DefaultDialect(Dialect): Dict[Tuple[Any, ...], Any], Dict[Any, Any], ] + if composite_sentinel: rows_by_sentinel = { tuple( diff --git a/lib/sqlalchemy/testing/provision.py b/lib/sqlalchemy/testing/provision.py index 97d64ceb1e..0f53db6463 100644 --- a/lib/sqlalchemy/testing/provision.py +++ b/lib/sqlalchemy/testing/provision.py @@ -600,3 +600,14 @@ def delete_from_all_tables(connection, cfg, metadata): connection.execute(table.delete()) else: connection.execute(table.delete()) + + +@register.init +def dbapi_error(cfg, cls, message): + """create a DBAPI error + + :param cls: the DBAPI class, like ``dialect.dbapi.OperationalError`` + :param message: message for the error + + """ + return cls(message) diff --git a/noxfile.py b/noxfile.py index e9d0c1fa93..6678dfa730 100644 --- a/noxfile.py +++ b/noxfile.py @@ -52,7 +52,10 @@ DB_CLI_NAMES = { "nogreenlet": {"cx_oracle", "oracledb"}, "greenlet": {"oracledb_async"}, }, - "mssql": {"nogreenlet": {"pyodbc", "pymssql"}, "greenlet": {"aioodbc"}}, + "mssql": { + "nogreenlet": {"pyodbc", "pymssql", "mssqlpython"}, + "greenlet": {"aioodbc"}, + }, } diff --git a/pyproject.toml b/pyproject.toml index 4676b346b8..022cc67c8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ mypy = [ mssql = ["pyodbc"] mssql-pymssql = ["pymssql"] mssql-pyodbc = ["pyodbc"] +mssql-python = ["mssql-python>=0.13.1"] mysql = ["mysqlclient>=1.4.0"] mysql-connector = ["mysql-connector-python"] mariadb-connector = ["mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10"] @@ -163,6 +164,7 @@ tests-oracle = [ tests-mssql = [ "sqlalchemy[mssql]", "sqlalchemy[mssql-pymssql]", + "sqlalchemy[mssql-python]", ] tests-mssql-asyncio = [ diff --git a/setup.cfg b/setup.cfg index 2700e67a98..97dff26d6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,6 +46,7 @@ mysql_connector = mariadb+mysqlconnector://scott:tiger@127.0.0.1:3306/test mssql = mssql+pyodbc://scott:tiger^5HHH@mssql2022:1433/test?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=Optional mssql_async = mssql+aioodbc://scott:tiger^5HHH@mssql2022:1433/test?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=Optional pymssql = mssql+pymssql://scott:tiger^5HHH@mssql2022:1433/test +mssql_python = mssql+mssqlpython://scott:tiger^5HHH@mssql2022:1433/test?Encrypt=No docker_mssql = mssql+pyodbc://scott:tiger^5HHH@127.0.0.1:1433/test?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=Optional oracle = oracle+oracledb://scott:tiger@oracle23c/freepdb1 cxoracle = oracle+cx_oracle://scott:tiger@oracle23c/freepdb1 diff --git a/test/dialect/mssql/test_query.py b/test/dialect/mssql/test_query.py index 33f648b82a..ce642b00ef 100644 --- a/test/dialect/mssql/test_query.py +++ b/test/dialect/mssql/test_query.py @@ -184,6 +184,11 @@ class QueryTest(testing.AssertsExecutionResults, fixtures.TestBase): older SCOPE_IDENTITY() call, which still works for this scenario. To enable the workaround, the Table must be instantiated with the init parameter 'implicit_returning = False'. + + Note: this **wont work** with SELECT @@identity. it has to be + SCOPE_IDENTITY(). for pyodbc, this means the function has to be + embedded on the same line as the INSERT. + """ # TODO: this same test needs to be tried in a multithreaded context @@ -305,7 +310,7 @@ class QueryTest(testing.AssertsExecutionResults, fixtures.TestBase): ), ) - @testing.only_on("mssql+pyodbc") + @testing.only_on(["mssql+pyodbc", "mssql+mssqlpython"]) @testing.provide_metadata def test_embedded_scope_identity(self): engine = engines.testing_engine(options={"use_scope_identity": True}) @@ -323,11 +328,20 @@ class QueryTest(testing.AssertsExecutionResults, fixtures.TestBase): with engine.begin() as conn: conn.execute(t1.insert(), {"data": "somedata"}) - # pyodbc-specific system + if testing.against("mssql+pyodbc"): + paramstyle = "?" + params = ("somedata",) + elif testing.against("mssql+mssqlpython"): + paramstyle = "%(data)s" + params = {"data": "somedata"} + else: + assert False + asserter.assert_( CursorSQL( - "INSERT INTO t1 (data) VALUES (?); select scope_identity()", - ("somedata",), + f"INSERT INTO t1 (data) VALUES ({paramstyle}); " + "select scope_identity()", + params, consume_statement=False, ) ) diff --git a/test/dialect/mssql/test_types.py b/test/dialect/mssql/test_types.py index 1f4bdc85f5..0f25b3a484 100644 --- a/test/dialect/mssql/test_types.py +++ b/test/dialect/mssql/test_types.py @@ -32,6 +32,7 @@ from sqlalchemy import Time from sqlalchemy import types from sqlalchemy import Unicode from sqlalchemy import UnicodeText +from sqlalchemy import VARCHAR from sqlalchemy.dialects.mssql import base as mssql from sqlalchemy.dialects.mssql import NTEXT from sqlalchemy.dialects.mssql import ROWVERSION @@ -961,12 +962,17 @@ class TypeRoundTripTest( ), None, True, + testing.fails_if( + "+mssqlpython", + "mssql-python does not seem to report this as an error", + ), ), ( "dto_param_datetime_naive", lambda: datetime.datetime(2007, 10, 30, 11, 2, 32, 123456), 0, False, + testing.fails_if("+pymssql", "seems to fail on pymssql"), ), ( "dto_param_string_one", @@ -981,11 +987,15 @@ class TypeRoundTripTest( 0, False, ), - ("dto_param_string_invalid", lambda: "this is not a date", 0, True), + ( + "dto_param_string_invalid", + lambda: "this is not a date", + 0, + True, + ), id_="iaaa", argnames="dto_param_value, expected_offset_hours, should_fail", ) - @testing.skip_if("+pymssql", "offsets dont seem to work") def test_datetime_offset( self, datetimeoffset_fixture, @@ -1271,14 +1281,7 @@ class StringTest(fixtures.TestBase, AssertsCompiledSQL): class StringRoundTripTest(fixtures.TestBase): - """tests for #8661 - - - at the moment most of these are using the default setinputsizes enabled - behavior, with the exception of the plain executemany() calls for inserts. - - - """ + """tests for #8661 as well as other string issues""" __only_on__ = "mssql" __backend__ = True @@ -1433,6 +1436,36 @@ class StringRoundTripTest(fixtures.TestBase): ) eq_(result.all(), ["some other data %d" % i for i in range(3)]) + @testing.combinations( + (3,), + (9,), + (10,), + (15,), + argnames="size", + ) + @testing.variation("fetchtype", ["all", "one"]) + def test_cp1252_varchar( + self, size, fetchtype: testing.Variation, metadata, connection + ): + """test for mssql-python's issue with small varchar + cp1252 strings""" + + t = Table("t", metadata, Column("data", VARCHAR(size))) + + t.create(connection) + + data = "réveillé réveillé"[0:size] + connection.execute(t.insert(), {"data": data}) + + if fetchtype.all: + result = connection.execute(select(t.c.data)) + rows = result.all() + received = rows[0][0] + elif fetchtype.one: + received = connection.scalar(select(t.c.data)) + else: + fetchtype.fail() + eq_(received, data) + class UniqueIdentifierTest(test_types.UuidTest): __only_on__ = "mssql" diff --git a/test/engine/test_execute.py b/test/engine/test_execute.py index 98a42f11e9..66ef5ce189 100644 --- a/test/engine/test_execute.py +++ b/test/engine/test_execute.py @@ -52,6 +52,7 @@ from sqlalchemy.testing import is_false from sqlalchemy.testing import is_not from sqlalchemy.testing import is_true from sqlalchemy.testing import ne_ +from sqlalchemy.testing import provision from sqlalchemy.testing.assertions import expect_deprecated from sqlalchemy.testing.assertsql import CompiledSQL from sqlalchemy.testing.provision import normalize_sequence @@ -794,6 +795,10 @@ class ExecuteDriverTest(fixtures.TablesTest): "+mysqlconnector", "Exception doesn't come back exactly the same from pickle", ) + @testing.fails_on( + "+mssqlpython", + "Exception pickling not working due to additional parameter", + ) @testing.fails_on( "oracle+cx_oracle", "cx_oracle exception seems to be having some issue with pickling", @@ -3039,7 +3044,11 @@ class HandleErrorTest(fixtures.TestBase): conn = engine.connect() def boom(connection): - raise engine.dialect.dbapi.OperationalError("rollback failed") + raise provision.dbapi_error( + config, + engine.dialect.dbapi.OperationalError, + "rollback failed", + ) with patch.object(conn.dialect, "do_rollback", boom): assert_raises_message( @@ -3066,7 +3075,11 @@ class HandleErrorTest(fixtures.TestBase): conn = engine.connect() def boom(connection): - raise engine.dialect.dbapi.OperationalError("rollback failed") + raise provision.dbapi_error( + config, + engine.dialect.dbapi.OperationalError, + "rollback failed", + ) @event.listens_for(conn, "begin") def _do_begin(conn): @@ -3256,7 +3269,11 @@ class HandleErrorTest(fixtures.TestBase): with patch.object( conn.dialect, "get_isolation_level", - Mock(side_effect=ProgrammingError("random error")), + Mock( + side_effect=provision.dbapi_error( + config, ProgrammingError, "random error" + ) + ), ): assert_raises(MySpecialException, conn.get_isolation_level) @@ -3532,7 +3549,9 @@ class OnConnectTest(fixtures.TestBase): dialect = testing.db.dialect dbapi = dialect.dbapi assert not dialect.is_disconnect( - dbapi.OperationalError("test"), None, None + provision.dbapi_error(config, dbapi.OperationalError, "test"), + None, + None, ) def test_dont_create_transaction_on_initialize(self): diff --git a/test/engine/test_reconnect.py b/test/engine/test_reconnect.py index f747d9ee05..cb04e0d3bd 100644 --- a/test/engine/test_reconnect.py +++ b/test/engine/test_reconnect.py @@ -19,6 +19,7 @@ from sqlalchemy.testing import assert_raises from sqlalchemy.testing import assert_raises_message from sqlalchemy.testing import assert_raises_message_context_ok from sqlalchemy.testing import assert_warns_message +from sqlalchemy.testing import config from sqlalchemy.testing import engines from sqlalchemy.testing import eq_ from sqlalchemy.testing import expect_raises @@ -29,6 +30,7 @@ from sqlalchemy.testing import is_false from sqlalchemy.testing import is_true from sqlalchemy.testing import mock from sqlalchemy.testing import ne_ +from sqlalchemy.testing import provision from sqlalchemy.testing.engines import DBAPIProxyConnection from sqlalchemy.testing.engines import DBAPIProxyCursor from sqlalchemy.testing.engines import testing_engine @@ -1037,7 +1039,9 @@ class RealPrePingEventHandlerTest(fixtures.TestBase): class ExplodeCursor(DBAPIProxyCursor): def execute(self, stmt, parameters=None, **kw): if fail and next(fail_count) < 1: - raise DBAPIError("unhandled disconnect situation") + raise provision.dbapi_error( + config, DBAPIError, "unhandled disconnect situation" + ) else: return super().execute(stmt, parameters=parameters, **kw) diff --git a/test/requirements.py b/test/requirements.py index 164c407d5e..af681b3166 100644 --- a/test/requirements.py +++ b/test/requirements.py @@ -325,8 +325,13 @@ class DefaultRequirements(SuiteRequirements): def non_broken_binary(self): """target DBAPI must work fully with binary values""" + # for pymssql # see https://github.com/pymssql/pymssql/issues/504 - return skip_if(["mssql+pymssql"]) + # + # for mssqlpython: + # Streaming parameters is not yet supported. Parameter size must be + # less than 8192 bytes + return skip_if(["mssql+pymssql", "mssql+mssqlpython"]) @property def binary_comparisons(self): @@ -758,6 +763,17 @@ class DefaultRequirements(SuiteRequirements): "no FOR UPDATE NOWAIT support", ) + @property + def unusual_column_name_characters(self): + """target database allows column names that have unusual characters + in them, such as dots, spaces, slashes, or percent signs. + + The column names are as always in such a case quoted, however the + DB still needs to support those characters in the name somehow. + + """ + return exclusions.skip_if("+mssqlpython", "waiting on GH issue 464") + @property def subqueries(self): """Target database must support subqueries.""" @@ -1614,7 +1630,7 @@ class DefaultRequirements(SuiteRequirements): """ - return exclusions.open() + return fails_on("+mssqlpython") @property def fetch_null_from_numeric(self): diff --git a/tox.ini b/tox.ini index 925c60aea7..5f051f295f 100644 --- a/tox.ini +++ b/tox.ini @@ -51,6 +51,7 @@ extras= oracle: oracle_oracledb mssql: mssql mssql-py{38,39,310,311,312,313}: mssql_pymssql + mssql: mssql-python install_command= # TODO: I can find no way to get pip / tox / anyone to have this @@ -156,8 +157,8 @@ setenv= # for mssql, aioodbc frequently segfaults under free-threaded builds mssql: MSSQL={env:TOX_MSSQL:--db mssql} - mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc --dbdriver pymssql} - mssql-py314: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc} + mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc --dbdriver pymssql --dbdriver mssqlpython} + mssql-py314: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc --dbdriver mssqlpython} mssql-{py313t,py314t,nogreenlet: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver pymssql} oracle,mssql,sqlite_file: IDENTS=--write-idents db_idents.txt