Repository: ibis-project/ibis

Issue:
bug(mssql): authentication issues
### What happened?

I keep getting authentication error when attempting to connect to `ibis.mssql.connect(...)`. In general, I connect to that database using pyodbc w/o password, and I'm not entirely sure how to set it up in ibis. 

### What version of ibis are you using?

5.1

### What backend(s) are you using, if any?

MSSQL

### Relevant log output

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
Hi @ededovic!

Can you show the traceback, or if not that, some more information from the error you're seeing? It's hard to tell what could be going wrong without that!
@ededovic Any updates here?

mssql connection is just lighlty wrapped by a class to pass the parameters. Typically I connect to it via odbc: 
`pyodbc.connect('DRIVER={ODBC Driver 17 for SQL Server};SERVER=' + self.host + ';DATABASE=' + self.database + ';Trusted_Connection=yes;')`

for ibis I'm passing this, but maybe it is not correct, as it is handled via windows authentication. I also tried by dropping password. 

`ibis.mssql.connect(user=self.user, password=self.paswd, host=self.host, database=self.database)`

This is edited snipped of stack trace of the error:
```
---------------------------------------------------------------------------
MSSQLDatabaseException                    Traceback (most recent call last)
File src\pymssql\_pymssql.pyx:647, in pymssql._pymssql.connect()
File src\pymssql\_mssql.pyx:2109, in pymssql._mssql.connect()
File src\pymssql\_mssql.pyx:701, in pymssql._mssql.MSSQLConnection.__init__()
File src\pymssql\_mssql.pyx:1818, in pymssql._mssql.maybe_raise_MSSQLDatabaseException()
File src\pymssql\_mssql.pyx:1835, in pymssql._mssql.raise_MSSQLDatabaseException()

MSSQLDatabaseException: (18456, b"Login failed for user 'ZZZ'.DB-Lib error message 20018, severity 14:\n
General SQL Server error: Check messages from the SQL Server\n
DB-Lib error message 20002, severity 9:\nAdaptive Server connection failed (HOST)\n
DB-Lib error message 20002, severity 9:\nAdaptive Server connection failed (HOST)\n")

During handling of the above exception, another exception occurred:

OperationalError  Traceback (most recent call last)
File PATH\envs\py310\lib\site-packages\sqlalchemy\engine\base.py:3361, in Engine._wrap_pool_connect(self, fn, connection)
   3360 try:
-> 3361     return fn()
   3362 except dialect.dbapi.Error as e:

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\base.py:327, in Pool.connect(self)
    320 """Return a DBAPI connection from the pool.
    321 
    322 The connection is instrumented such that when its
   (...)
    325 
    326 """
--> 327 return _ConnectionFairy._checkout(self)

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\base.py:894, in _ConnectionFairy._checkout(cls, pool, threadconns, fairy)
    893 if not fairy:
--> 894     fairy = _ConnectionRecord.checkout(pool)
    896     fairy._pool = pool

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\base.py:493, in _ConnectionRecord.checkout(cls, pool)
    491 @classmethod
    492 def checkout(cls, pool):
--> 493     rec = pool._do_get()
    494     try:

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\impl.py:445, in StaticPool._do_get(self)
    444 def _do_get(self):
--> 445     rec = self.connection
    446     if rec._is_hard_or_soft_invalidated():

File PATH\envs\py310\lib\site-packages\sqlalchemy\util\langhelpers.py:1113, in memoized_property.__get__(self, obj, cls)
   1112     return self
-> 1113 obj.__dict__[self.__name__] = result = self.fget(obj)
   1114 return result

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\impl.py:405, in StaticPool.connection(self)
    403 @util.memoized_property
    404 def connection(self):
--> 405     return _ConnectionRecord(self)

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\base.py:388, in _ConnectionRecord.__init__(self, pool, connect)
    387 if connect:
--> 388     self.__connect()
    389 self.finalize_callback = deque()

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\base.py:690, in _ConnectionRecord.__connect(self)
    689 except BaseException as e:
--> 690     with util.safe_reraise():
    691         pool.logger.debug("Error on connect(): %s", e)

File PATH\envs\py310\lib\site-packages\sqlalchemy\util\langhelpers.py:70, in safe_reraise.__exit__(self, type_, value, traceback)
     69     if not self.warn_only:
---> 70         compat.raise_(
     71             exc_value,
     72             with_traceback=exc_tb,
     73         )
     74 else:

File PATH\envs\py310\lib\site-packages\sqlalchemy\util\compat.py:211, in raise_(***failed resolving arguments***)
    210 try:
--> 211     raise exception
    212 finally:
    213     # credit to
    214     # https://cosmicpercolator.com/2016/01/13/exception-leaks-in-python-2-and-3/
    215     # as the __traceback__ object creates a cycle

File PATH\envs\py310\lib\site-packages\sqlalchemy\pool\base.py:686, in _ConnectionRecord.__connect(self)
    685 self.starttime = time.time()
--> 686 self.dbapi_connection = connection = pool._invoke_creator(self)
    687 pool.logger.debug("Created new connection %r", connection)

File PATH\envs\py310\lib\site-packages\sqlalchemy\engine\create.py:578, in create_engine..connect(connection_record)
    577             return connection
--> 578 return dialect.connect(*cargs, **cparams)

File PATH\envs\py310\lib\site-packages\sqlalchemy\engine\default.py:598, in DefaultDialect.connect(self, *cargs, **cparams)
    596 def connect(self, *cargs, **cparams):
    597     # inherits the docstring from interfaces.Dialect.connect
--> 598     return self.dbapi.connect(*cargs, **cparams)

File src\pymssql\_pymssql.pyx:653, in pymssql._pymssql.connect()

OperationalError: (18456, b"Login failed for user 'ZZZ'.DB-Lib error message 20018, severity 14:\nGeneral SQL Server error: Check messages from the SQL Server\nDB-Lib error message 20002, severity 9:\nAdaptive Server connection failed (HOST)\nDB-Lib error message 20002, severity 9:\nAdaptive Server connection failed (HOST)\n")




```

Resolving patch:
diff --git a/ibis/backends/base/sql/alchemy/__init__.py b/ibis/backends/base/sql/alchemy/__init__.py
--- a/ibis/backends/base/sql/alchemy/__init__.py
+++ b/ibis/backends/base/sql/alchemy/__init__.py
@@ -3,7 +3,6 @@
 import abc
 import atexit
 import contextlib
-import getpass
 import warnings
 from operator import methodcaller
 from typing import TYPE_CHECKING, Any
@@ -134,11 +133,20 @@ def _compile_type(self, dtype) -> str:
             self.compiler.translator_class.get_sqla_type(dtype)
         ).compile(dialect=dialect)
 
-    def _build_alchemy_url(self, url, host, port, user, password, database, driver):
+    def _build_alchemy_url(
+        self,
+        url: str | None,
+        host: str | None,
+        port: int | None,
+        user: str | None,
+        password: str | None,
+        database: str | None,
+        driver: str | None,
+        query: Mapping[str, Any] | None = None,
+    ) -> sa.engine.URL:
         if url is not None:
             return sa.engine.url.make_url(url)
 
-        user = user or getpass.getuser()
         return sa.engine.url.URL.create(
             driver,
             host=host,
@@ -146,6 +154,7 @@ def _build_alchemy_url(self, url, host, port, user, password, database, driver):
             username=user,
             password=password,
             database=database,
+            query=query or {},
         )
 
     @property
@@ -817,8 +826,11 @@ def _get_compiled_statement(
         compiled = definition.compile(
             dialect=self.con.dialect, compile_kwargs=compile_kwargs
         )
-        lines = self._get_temp_view_definition(name, definition=compiled)
-        return lines, compiled.params
+        create_view = self._get_temp_view_definition(name, definition=compiled)
+        params = compiled.params
+        if compiled.positional:
+            params = tuple(params.values())
+        return create_view, params
 
     def _create_temp_view(self, view: sa.Table, definition: sa.sql.Selectable) -> None:
         raw_name = view.name
diff --git a/ibis/backends/mssql/__init__.py b/ibis/backends/mssql/__init__.py
--- a/ibis/backends/mssql/__init__.py
+++ b/ibis/backends/mssql/__init__.py
@@ -2,7 +2,8 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Any, Literal
+import contextlib
+from typing import TYPE_CHECKING, Any
 
 import sqlalchemy as sa
 import toolz
@@ -34,10 +35,16 @@ def do_connect(
         port: int = 1433,
         database: str | None = None,
         url: str | None = None,
-        driver: Literal["pymssql"] = "pymssql",
+        query: Mapping[str, Any] | None = None,
+        driver: str | None = None,
+        **kwargs: Any,
     ) -> None:
-        if driver != "pymssql":
-            raise NotImplementedError("pymssql is currently the only supported driver")
+        if query is None:
+            query = {}
+
+        if driver is not None:
+            query["driver"] = driver
+
         alchemy_url = self._build_alchemy_url(
             url=url,
             host=host,
@@ -45,10 +52,13 @@ def do_connect(
             user=user,
             password=password,
             database=database,
-            driver=f"mssql+{driver}",
+            driver="mssql+pyodbc",
+            query=query,
         )
 
-        engine = sa.create_engine(alchemy_url, poolclass=sa.pool.StaticPool)
+        engine = sa.create_engine(
+            alchemy_url, poolclass=sa.pool.StaticPool, connect_args=kwargs
+        )
 
         @sa.event.listens_for(engine, "connect")
         def connect(dbapi_connection, connection_record):
@@ -84,6 +94,21 @@ def list_databases(self, like: str | None = None) -> list[str]:
     def current_schema(self) -> str:
         return self._scalar_query(sa.select(sa.func.schema_name()))
 
+    @contextlib.contextmanager
+    def _safe_raw_sql(self, stmt, *args, **kwargs):
+        sql = str(
+            stmt.compile(
+                dialect=self.con.dialect, compile_kwargs={"literal_binds": True}
+            )
+        )
+        with self.begin() as con:
+            yield con.exec_driver_sql(sql, *args, **kwargs)
+
+    def _get_compiled_statement(self, view: sa.Table, definition: sa.sql.Selectable):
+        return super()._get_compiled_statement(
+            view, definition, compile_kwargs={"literal_binds": True}
+        )
+
     def _get_temp_view_definition(
         self, name: str, definition: sa.sql.compiler.Compiled
     ) -> str:
diff --git a/ibis/backends/mssql/registry.py b/ibis/backends/mssql/registry.py
--- a/ibis/backends/mssql/registry.py
+++ b/ibis/backends/mssql/registry.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import sqlalchemy as sa
+from sqlalchemy.dialects import mssql
 from sqlalchemy.ext.compiler import compiles
 
 import ibis.common.exceptions as com
@@ -12,6 +13,9 @@
     sqlalchemy_window_functions_registry,
     unary,
 )
+from ibis.backends.base.sql.alchemy.registry import (
+    _literal as _alchemy_literal,
+)
 from ibis.backends.base.sql.alchemy.registry import substr, variance_reduction
 
 
@@ -112,6 +116,39 @@ def _temporal_delta(t, op):
     return sa.func.datediff(sa.literal_column(op.part.value.upper()), right, left)
 
 
+def _literal(t, op):
+    dtype = op.dtype
+    value = op.value
+
+    if value is not None:
+        if dtype.is_timestamp():
+            return sa.func.datetime2fromparts(
+                value.year,
+                value.month,
+                value.day,
+                value.hour,
+                value.minute,
+                value.second,
+                value.microsecond,
+                6,
+            )
+        elif dtype.is_date():
+            return sa.func.datefromparts(value.year, value.month, value.day)
+        elif dtype.is_time():
+            return sa.func.timefromparts(
+                value.hour,
+                value.minute,
+                value.second,
+                value.microsecond,
+                sa.literal_column("6"),
+            )
+        elif dtype.is_uuid():
+            return sa.cast(sa.literal(str(value)), mssql.UNIQUEIDENTIFIER)
+        elif dtype.is_binary():
+            return sa.cast(sa.literal(value), mssql.VARBINARY)
+    return _alchemy_literal(t, op)
+
+
 operation_registry = sqlalchemy_operation_registry.copy()
 operation_registry.update(sqlalchemy_window_functions_registry)
 
@@ -195,7 +232,7 @@ def _temporal_delta(t, op):
             6,
         ),
         ops.TimeFromHMS: fixed_arity(
-            lambda h, m, s: sa.func.timefromparts(h, m, s, 0, 0), 3
+            lambda h, m, s: sa.func.timefromparts(h, m, s, 0, sa.literal_column("0")), 3
         ),
         ops.TimestampTruncate: _timestamp_truncate,
         ops.DateTruncate: _timestamp_truncate,
@@ -206,6 +243,7 @@ def _temporal_delta(t, op):
         ops.TimeDelta: _temporal_delta,
         ops.DateDelta: _temporal_delta,
         ops.TimestampDelta: _temporal_delta,
+        ops.Literal: _literal,
     }
 )
 


Test patch:
diff --git a/ibis/backends/mssql/tests/conftest.py b/ibis/backends/mssql/tests/conftest.py
--- a/ibis/backends/mssql/tests/conftest.py
+++ b/ibis/backends/mssql/tests/conftest.py
@@ -19,6 +19,7 @@
 MSSQL_HOST = os.environ.get("IBIS_TEST_MSSQL_HOST", "localhost")
 MSSQL_PORT = int(os.environ.get("IBIS_TEST_MSSQL_PORT", 1433))
 IBIS_TEST_MSSQL_DB = os.environ.get("IBIS_TEST_MSSQL_DATABASE", "ibis_testing")
+MSSQL_PYODBC_DRIVER = os.environ.get("IBIS_TEST_MSSQL_PYODBC_DRIVER", "FreeTDS")
 
 
 class TestConf(ServiceBackendTest, RoundHalfToEven):
@@ -31,7 +32,7 @@ class TestConf(ServiceBackendTest, RoundHalfToEven):
     supports_structs = False
     supports_json = False
     service_name = "mssql"
-    deps = "pymssql", "sqlalchemy"
+    deps = "pyodbc", "sqlalchemy"
 
     @property
     def test_files(self) -> Iterable[Path]:
@@ -56,10 +57,12 @@ def _load_data(
         script_dir
             Location of scripts defining schemas
         """
+        params = f"driver={MSSQL_PYODBC_DRIVER}"
+        url = sa.engine.make_url(
+            f"mssql+pyodbc://{user}:{password}@{host}:{port:d}/{database}?{params}"
+        )
         init_database(
-            url=sa.engine.make_url(
-                f"mssql+pymssql://{user}:{password}@{host}:{port:d}/{database}"
-            ),
+            url=url,
             database=database,
             schema=self.ddl_script,
             isolation_level="AUTOCOMMIT",
@@ -74,6 +77,7 @@ def connect(*, tmpdir, worker_id, **kw):
             password=MSSQL_PASS,
             database=IBIS_TEST_MSSQL_DB,
             port=MSSQL_PORT,
+            driver=MSSQL_PYODBC_DRIVER,
             **kw,
         )
 
diff --git a/ibis/backends/mssql/tests/test_client.py b/ibis/backends/mssql/tests/test_client.py
--- a/ibis/backends/mssql/tests/test_client.py
+++ b/ibis/backends/mssql/tests/test_client.py
@@ -120,7 +120,7 @@ def count_big(x, where: bool = True) -> int:
     ft = con.tables.functional_alltypes
     expr = count_big(ft.id)
     with pytest.raises(
-        sa.exc.OperationalError, match="An expression of non-boolean type specified"
+        sa.exc.ProgrammingError, match="An expression of non-boolean type specified"
     ):
         assert expr.execute()
 
diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py
--- a/ibis/backends/tests/test_client.py
+++ b/ibis/backends/tests/test_client.py
@@ -526,7 +526,7 @@ def test_list_databases(alchemy_con):
 @pytest.mark.never(
     ["bigquery", "postgres", "mssql", "mysql", "snowflake", "oracle"],
     reason="backend does not support client-side in-memory tables",
-    raises=(sa.exc.OperationalError, TypeError),
+    raises=(sa.exc.OperationalError, TypeError, sa.exc.InterfaceError),
 )
 @pytest.mark.notyet(
     ["trino"], reason="memory connector doesn't allow writing to tables"
diff --git a/ibis/backends/tests/test_export.py b/ibis/backends/tests/test_export.py
--- a/ibis/backends/tests/test_export.py
+++ b/ibis/backends/tests/test_export.py
@@ -330,7 +330,10 @@ def test_table_to_csv(tmp_path, backend, awards_players):
                 ),
                 pytest.mark.notyet(["oracle"], raises=sa.exc.DatabaseError),
                 pytest.mark.notyet(["dask"], raises=NotImplementedError),
-                pytest.mark.notyet(["mssql", "mysql"], raises=sa.exc.OperationalError),
+                pytest.mark.notyet(
+                    ["mssql", "mysql"],
+                    raises=(sa.exc.OperationalError, sa.exc.ProgrammingError),
+                ),
                 pytest.mark.notyet(["pyspark"], raises=ParseException),
             ],
         ),
@@ -394,7 +397,6 @@ def test_roundtrip_delta(con, alltypes, tmp_path, monkeypatch):
 @pytest.mark.notimpl(
     ["druid"], raises=AttributeError, reason="string type is used for timestamp_col"
 )
-@pytest.mark.notimpl(["mssql"], raises=pa.ArrowTypeError)
 @pytest.mark.notimpl(
     ["pyspark"], raises=NotImplementedError, reason="fetchmany not implemented"
 )
diff --git a/ibis/backends/tests/test_generic.py b/ibis/backends/tests/test_generic.py
--- a/ibis/backends/tests/test_generic.py
+++ b/ibis/backends/tests/test_generic.py
@@ -751,7 +751,7 @@ def test_select_filter_select(backend, alltypes, df):
 
 
 @pytest.mark.notimpl(["datafusion"], raises=com.OperationNotDefinedError)
-@pytest.mark.broken(["mssql"], raises=sa.exc.OperationalError)
+@pytest.mark.broken(["mssql"], raises=sa.exc.ProgrammingError)
 def test_between(backend, alltypes, df):
     expr = alltypes.double_col.between(5, 10)
     result = expr.execute().rename("double_col")
diff --git a/ibis/backends/tests/test_numeric.py b/ibis/backends/tests/test_numeric.py
--- a/ibis/backends/tests/test_numeric.py
+++ b/ibis/backends/tests/test_numeric.py
@@ -454,7 +454,7 @@ def test_numeric_literal(con, backend, expr, expected_types):
                     "DB-Lib error message 20018, severity 16:\nGeneral SQL Server error: "
                     'Check messages from the SQL Server\n")'
                     "[SQL: SELECT %(param_1)s AS [Decimal('Infinity')]]",
-                    raises=sa.exc.ProgrammingError,
+                    raises=(sa.exc.ProgrammingError, KeyError),
                 ),
                 pytest.mark.broken(
                     ["druid"],
@@ -534,7 +534,7 @@ def test_numeric_literal(con, backend, expr, expected_types):
                     "DB-Lib error message 20018, severity 16:\nGeneral SQL Server error: "
                     'Check messages from the SQL Server\n")'
                     "[SQL: SELECT %(param_1)s AS [Decimal('-Infinity')]]",
-                    raises=sa.exc.ProgrammingError,
+                    raises=(sa.exc.ProgrammingError, KeyError),
                 ),
                 pytest.mark.broken(
                     ["druid"],
@@ -617,7 +617,7 @@ def test_numeric_literal(con, backend, expr, expected_types):
                     "DB-Lib error message 20018, severity 16:\nGeneral SQL Server error: "
                     'Check messages from the SQL Server\n")'
                     "[SQL: SELECT %(param_1)s AS [Decimal('NaN')]]",
-                    raises=sa.exc.ProgrammingError,
+                    raises=(sa.exc.ProgrammingError, KeyError),
                 ),
                 pytest.mark.broken(
                     ["mssql"],
@@ -1182,7 +1182,9 @@ def test_mod(backend, alltypes, df):
     backend.assert_series_equal(result, expected, check_dtype=False)
 
 
-@pytest.mark.notimpl(["mssql"], raises=sa.exc.OperationalError)
+@pytest.mark.notimpl(
+    ["mssql"], raises=(sa.exc.OperationalError, sa.exc.ProgrammingError)
+)
 @pytest.mark.notyet(
     ["druid"], raises=AssertionError, reason="mod with floats is integer mod"
 )
@@ -1262,7 +1264,7 @@ def test_floating_mod(backend, alltypes, df):
     ],
 )
 @pytest.mark.notyet(["duckdb", "mysql", "pyspark", "sqlite"], raises=AssertionError)
-@pytest.mark.notyet(["mssql"], raises=sa.exc.OperationalError)
+@pytest.mark.notyet(["mssql"], raises=(sa.exc.OperationalError, sa.exc.DataError))
 @pytest.mark.notyet(["postgres"], raises=sa.exc.DataError)
 @pytest.mark.notyet(["snowflake"], raises=sa.exc.ProgrammingError)
 @pytest.mark.xfail_version(datafusion=["datafusion<31"], raises=Exception)
diff --git a/ibis/backends/tests/test_string.py b/ibis/backends/tests/test_string.py
--- a/ibis/backends/tests/test_string.py
+++ b/ibis/backends/tests/test_string.py
@@ -144,7 +144,7 @@ def uses_java_re(t):
                 pytest.mark.broken(
                     ["mssql"],
                     reason="mssql doesn't allow like outside of filters",
-                    raises=sa.exc.OperationalError,
+                    raises=sa.exc.ProgrammingError,
                 ),
             ],
         ),
@@ -159,7 +159,7 @@ def uses_java_re(t):
                 pytest.mark.broken(
                     ["mssql"],
                     reason="mssql doesn't allow like outside of filters",
-                    raises=sa.exc.OperationalError,
+                    raises=sa.exc.ProgrammingError,
                 ),
             ],
         ),
@@ -174,7 +174,7 @@ def uses_java_re(t):
                 pytest.mark.broken(
                     ["mssql"],
                     reason="mssql doesn't allow like outside of filters",
-                    raises=sa.exc.OperationalError,
+                    raises=sa.exc.ProgrammingError,
                 ),
             ],
         ),
@@ -190,7 +190,7 @@ def uses_java_re(t):
                 pytest.mark.broken(
                     ["mssql"],
                     reason="mssql doesn't allow like outside of filters",
-                    raises=sa.exc.OperationalError,
+                    raises=sa.exc.ProgrammingError,
                 ),
             ],
         ),
@@ -535,22 +535,7 @@ def uses_java_re(t):
                     ["dask", "datafusion", "pyspark"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(
-                    ["druid"],
-                    raises=sa.exc.ProgrammingError,
-                ),
-                pytest.mark.broken(
-                    ["mssql"],
-                    raises=sa.exc.OperationalError,
-                    reason=(
-                        '(pymssql._pymssql.OperationalError) (156, b"Incorrect syntax near the keyword '
-                        "'LIKE'.DB-Lib error message 20018, severity 15:\nGeneral SQL Server error: "
-                        'Check messages from the SQL Server\n")'
-                        "[SQL: SELECT (CASE t0.int_col WHEN %(param_1)s THEN %(param_2)s WHEN %(param_3)s "
-                        "THEN %(param_4)s ELSE %(param_5)s END LIKE %(param_6)s + '%') AS tmp"
-                        "FROM functional_alltypes AS t0]"
-                    ),
-                ),
+                pytest.mark.broken(["druid", "mssql"], raises=sa.exc.ProgrammingError),
             ],
         ),
         param(
@@ -565,16 +550,7 @@ def uses_java_re(t):
                     ["dask", "datafusion", "pyspark"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["druid"], raises=sa.exc.ProgrammingError),
-                pytest.mark.broken(
-                    ["mssql"],
-                    reason=(
-                        '(pymssql._pymssql.OperationalError) (156, b"Incorrect syntax near '
-                        "the keyword 'LIKE'.DB-Lib error message 20018, severity 15:\n"
-                        'General SQL Server error: Check messages from the SQL Server\n")'
-                    ),
-                    raises=sa.exc.OperationalError,
-                ),
+                pytest.mark.broken(["druid", "mssql"], raises=sa.exc.ProgrammingError),
             ],
         ),
         param(
@@ -586,15 +562,7 @@ def uses_java_re(t):
                     ["dask"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(
-                    ["mssql"],
-                    raises=sa.exc.OperationalError,
-                    reason=(
-                        '(pymssql._pymssql.OperationalError) (156, b"Incorrect syntax near '
-                        "the keyword 'LIKE'.DB-Lib error message 20018, severity 15:\n"
-                        'General SQL Server error: Check messages from the SQL Server\n")'
-                    ),
-                ),
+                pytest.mark.broken(["mssql"], raises=sa.exc.ProgrammingError),
             ],
         ),
         param(
@@ -606,15 +574,7 @@ def uses_java_re(t):
                     ["dask", "datafusion"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(
-                    ["mssql"],
-                    raises=sa.exc.OperationalError,
-                    reason=(
-                        '(pymssql._pymssql.OperationalError) (156, b"Incorrect syntax near '
-                        "the keyword 'LIKE'.DB-Lib error message 20018, severity 15:\n"
-                        'General SQL Server error: Check messages from the SQL Server\n")'
-                    ),
-                ),
+                pytest.mark.broken(["mssql"], raises=sa.exc.ProgrammingError),
             ],
         ),
         param(
@@ -659,7 +619,7 @@ def uses_java_re(t):
                 pytest.mark.broken(
                     ["mssql"],
                     reason="substr requires 3 arguments",
-                    raises=sa.exc.OperationalError,
+                    raises=sa.exc.ProgrammingError,
                 ),
             ],
         ),
@@ -867,14 +827,7 @@ def test_re_replace_global(con):
 
 
 @pytest.mark.notimpl(["datafusion"], raises=com.OperationNotDefinedError)
-@pytest.mark.broken(
-    ["mssql"],
-    raises=sa.exc.OperationalError,
-    reason=(
-        '(pymssql._pymssql.OperationalError) (4145, b"An expression of non-boolean type specified in '
-        "a context where a condition is expected, near 'THEN'.DB-Lib error message 20018, severity 15:\n"
-    ),
-)
+@pytest.mark.broken(["mssql"], raises=sa.exc.ProgrammingError)
 @pytest.mark.notimpl(["druid"], raises=ValidationError)
 @pytest.mark.broken(
     ["oracle"],
@@ -1057,7 +1010,7 @@ def test_levenshtein(con, right):
 @pytest.mark.notyet(
     ["mssql"],
     reason="doesn't allow boolean expressions in select statements",
-    raises=sa.exc.OperationalError,
+    raises=sa.exc.ProgrammingError,
 )
 @pytest.mark.broken(
     ["oracle"],
diff --git a/ibis/backends/tests/test_temporal.py b/ibis/backends/tests/test_temporal.py
--- a/ibis/backends/tests/test_temporal.py
+++ b/ibis/backends/tests/test_temporal.py
@@ -1809,18 +1809,12 @@ def test_timestamp_literal(con, backend):
 @pytest.mark.parametrize(
     ("timezone", "expected"),
     [
-        param(
-            "Europe/London",
-            "2022-02-04 16:20:00GMT",
-            id="name",
-            marks=[pytest.mark.broken(["mssql"], raises=TypeError)],
-        ),
+        param("Europe/London", "2022-02-04 16:20:00GMT", id="name"),
         param(
             "PST8PDT",
             "2022-02-04 08:20:00PST",
             # The time zone for Berkeley, California.
             id="iso",
-            marks=[pytest.mark.broken(["mssql"], raises=TypeError)],
         ),
     ],
 )
@@ -1837,7 +1831,7 @@ def test_timestamp_literal(con, backend):
         "<NUMERIC>, <NUMERIC>, <NUMERIC>, <NUMERIC>)"
     ),
 )
-def test_timestamp_with_timezone_literal(con, backend, timezone, expected):
+def test_timestamp_with_timezone_literal(con, timezone, expected):
     expr = ibis.timestamp(2022, 2, 4, 16, 20, 0).cast(dt.Timestamp(timezone=timezone))
     result = con.execute(expr)
     if not isinstance(result, str):
@@ -1919,7 +1913,7 @@ def test_time_literal(con, backend):
             561021,
             marks=[
                 pytest.mark.notimpl(
-                    ["mssql", "mysql"],
+                    ["mysql"],
                     raises=AssertionError,
                     reason="doesn't have enough precision to capture microseconds",
                 ),
@@ -2322,24 +2316,13 @@ def test_large_timestamp(con):
 @pytest.mark.parametrize(
     ("ts", "scale", "unit"),
     [
-        param(
-            "2023-01-07 13:20:05.561",
-            3,
-            "ms",
-            id="ms",
-            marks=pytest.mark.broken(
-                ["mssql"], reason="incorrect result", raises=AssertionError
-            ),
-        ),
+        param("2023-01-07 13:20:05.561", 3, "ms", id="ms"),
         param(
             "2023-01-07 13:20:05.561021",
             6,
             "us",
             id="us",
             marks=[
-                pytest.mark.broken(
-                    ["mssql"], reason="incorrect result", raises=AssertionError
-                ),
                 pytest.mark.notyet(
                     ["sqlite"],
                     reason="doesn't support microseconds",
@@ -2366,7 +2349,7 @@ def test_large_timestamp(con):
                 pytest.mark.notyet(
                     ["mssql"],
                     reason="doesn't support nanoseconds",
-                    raises=sa.exc.OperationalError,
+                    raises=sa.exc.ProgrammingError,
                 ),
                 pytest.mark.notyet(
                     ["bigquery"],
diff --git a/ibis/backends/tests/test_window.py b/ibis/backends/tests/test_window.py
--- a/ibis/backends/tests/test_window.py
+++ b/ibis/backends/tests/test_window.py
@@ -692,7 +692,8 @@ def test_simple_ungrouped_window_with_scalar_order_by(backend, alltypes):
                     raises=AnalysisException,
                     reason="pyspark requires ORDER BY",
                 ),
-                pytest.mark.broken(["mssql", "mysql"], raises=sa.exc.OperationalError),
+                pytest.mark.broken(["mysql"], raises=sa.exc.OperationalError),
+                pytest.mark.broken(["mssql"], raises=sa.exc.ProgrammingError),
                 pytest.mark.notyet(
                     ["snowflake"],
                     reason="backend requires ordering",
@@ -726,7 +727,8 @@ def test_simple_ungrouped_window_with_scalar_order_by(backend, alltypes):
                     raises=AnalysisException,
                     reason="pyspark requires ORDER BY",
                 ),
-                pytest.mark.broken(["mssql", "mysql"], raises=sa.exc.OperationalError),
+                pytest.mark.broken(["mysql"], raises=sa.exc.OperationalError),
+                pytest.mark.broken(["mssql"], raises=sa.exc.ProgrammingError),
                 pytest.mark.notyet(
                     ["snowflake"],
                     reason="backend requires ordering",
@@ -837,11 +839,7 @@ def test_ungrouped_unbounded_window(
     reason="RANGE OFFSET frame for 'DB::ColumnNullable' ORDER BY column is not implemented",
     raises=ClickHouseOperationalError,
 )
-@pytest.mark.notyet(
-    ["mssql"],
-    reason="RANGE is only supported with UNBOUNDED and CURRENT ROW window frame delimiters",
-    raises=sa.exc.OperationalError,
-)
+@pytest.mark.notyet(["mssql"], raises=sa.exc.ProgrammingError)
 def test_grouped_bounded_range_window(backend, alltypes, df):
     # Explanation of the range window spec below:
     #