From c7633762ed9bc160da1850a82fcf9b64b9794cce Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:03:11 +0000 Subject: [PATCH 01/16] feat(mssql): add MssqlPythonADKMemoryStore and pin SQL Server store parity (#778) --- sqlspec/adapters/mssql_python/adk/__init__.py | 4 +- sqlspec/adapters/mssql_python/adk/store.py | 215 +++++++++++++++++- .../test_mssql_python/test_adk_store.py | 59 ++++- .../unit/adapters/test_mssql_store_parity.py | 97 ++++++++ .../test_adk/test_store_instantiation.py | 12 + 5 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 tests/unit/adapters/test_mssql_store_parity.py diff --git a/sqlspec/adapters/mssql_python/adk/__init__.py b/sqlspec/adapters/mssql_python/adk/__init__.py index ed8a4594c..bb43d7160 100644 --- a/sqlspec/adapters/mssql_python/adk/__init__.py +++ b/sqlspec/adapters/mssql_python/adk/__init__.py @@ -1,5 +1,5 @@ """mssql-python ADK store exports.""" -from sqlspec.adapters.mssql_python.adk.store import MssqlPythonADKConfig, MssqlPythonADKStore +from sqlspec.adapters.mssql_python.adk.store import MssqlPythonADKConfig, MssqlPythonADKMemoryStore, MssqlPythonADKStore -__all__ = ("MssqlPythonADKConfig", "MssqlPythonADKStore") +__all__ = ("MssqlPythonADKConfig", "MssqlPythonADKMemoryStore", "MssqlPythonADKStore") diff --git a/sqlspec/adapters/mssql_python/adk/store.py b/sqlspec/adapters/mssql_python/adk/store.py index 29db81b26..4011778fa 100644 --- a/sqlspec/adapters/mssql_python/adk/store.py +++ b/sqlspec/adapters/mssql_python/adk/store.py @@ -2,7 +2,7 @@ import re from datetime import datetime -from typing import TYPE_CHECKING, Any, ClassVar, Final, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, cast from typing_extensions import NotRequired @@ -10,16 +10,19 @@ from sqlspec.adapters.mssql_python.data_dictionary import MssqlVersionInfo from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options +from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: + from collections.abc import Sequence from datetime import timedelta from sqlspec.adapters.mssql_python.config import MssqlPythonConfig from sqlspec.adapters.mssql_python.driver import MssqlPythonDriver from sqlspec.extensions.adk import SessionOrderBy + from sqlspec.extensions.adk.memory._types import StoredMemory -__all__ = ("MssqlPythonADKConfig", "MssqlPythonADKStore") +__all__ = ("MssqlPythonADKConfig", "MssqlPythonADKMemoryStore", "MssqlPythonADKStore") MSSQL_TABLE_NOT_FOUND_ERROR: Final[int] = 208 MSSQL_DUPLICATE_OBJECT_ERROR: Final[int] = 2714 @@ -415,6 +418,179 @@ def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = F return rowcount +class MssqlPythonADKMemoryStore(BaseSyncADKMemoryStore["MssqlPythonConfig"]): + """SQL Server ADK memory store using mssql-python.""" + + __slots__ = () + + def __init__(self, config: "MssqlPythonConfig") -> None: + super().__init__(config) + + def create_tables(self) -> None: + """Create the memory table (idempotent T-SQL) and DD-gated indexes.""" + if not self.create_schema_enabled: + self.reconcile_schema() + return + + if not self._enabled: + return + with self._config.provide_session() as driver: + driver.execute_script(self._memory_table_ddl()) + existing_indexes = _casefold_names( + driver.data_dictionary.get_indexes(driver, schema=MSSQL_SCHEMA), "index_name" + ) + for index_name, index_table, columns in self._memory_index_specs(): + if _bare_name(index_name) not in existing_indexes: + driver.execute(_create_index_sql(index_table, index_name, columns)) + driver.commit() + + def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: + """Bulk insert memory entries with event-id deduplication.""" + if not self._enabled: + msg = "ADK memory store is disabled" + raise RuntimeError(msg) + if not entries: + return 0 + + owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" + owner_value = ", ?" if self._owner_id_column_name else "" + sql = f""" + IF NOT EXISTS (SELECT 1 FROM {_table_ref(self._memory_table)} WHERE event_id = ?) + BEGIN + INSERT INTO {_table_ref(self._memory_table)} ( + id, session_id, app_name, user_id, scope, event_id, author, timestamp, + content_json, content_text, metadata_json{owner_column} + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{owner_value}); + END; + """ + inserted = 0 + with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: + for entry in entries: + params: tuple[Any, ...] = ( + entry["event_id"], + entry["id"], + entry["session_id"], + entry["app_name"], + entry["user_id"], + entry.get("scope", "user"), + entry["event_id"], + entry.get("author"), + entry["timestamp"], + to_json(entry["content_json"]), + entry["content_text"], + to_json(entry.get("metadata_json")), + ) + if self._owner_id_column_name: + params = (*params, owner_id) + cursor.execute(sql, params) + inserted += _cursor_rowcount(cursor) + conn.commit() + return inserted + + def search_entries( + self, + query: str, + app_name: str, + user_id: str, + limit: "int | None" = None, + scope_filter: Literal["all", "user", "app"] = "all", + embedding: "Sequence[float] | None" = None, + ) -> "list[StoredMemory]": + """Search memory entries by text query.""" + if not self._enabled: + msg = "ADK memory store is disabled" + raise RuntimeError(msg) + limit_value = limit or self._max_results + where_scope, scope_params = _build_mssql_scope_where(app_name, user_id, scope_filter) + sql = f""" + SELECT TOP (?) + id, session_id, app_name, user_id, scope, event_id, author, timestamp, + content_json, content_text, metadata_json, inserted_at + FROM {_table_ref(self._memory_table)} + WHERE {where_scope} AND content_text LIKE ? + ORDER BY timestamp DESC + """ + rows = self._execute_fetchall(sql, (limit_value, *scope_params, f"%{query}%")) + return [_memory_record_from_row(row) for row in rows] + + def delete_entries_by_session(self, session_id: str) -> int: + """Delete all memory entries for a specific session.""" + return self._execute( + f"DELETE FROM {_table_ref(self._memory_table)} WHERE session_id = ?", (session_id,), commit=True + ) + + def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: + """Delete memory entries older than the retention window.""" + clauses = ["inserted_at < DATEADD(day, -?, SYSUTCDATETIME())"] + params: list[Any] = [days] + if app_name is not None: + clauses.append("app_name = ?") + params.append(app_name) + if scope is not None: + clauses.append("scope = ?") + params.append(scope) + where_sql = " AND ".join(clauses) + return self._execute( + f"DELETE FROM {_table_ref(self._memory_table)} WHERE {where_sql}", tuple(params), commit=True + ) + + def _memory_table_ddl(self) -> str: + owner_line = f",\n {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(self._memory_table)}' + AND schema_id = SCHEMA_ID(N'dbo')) +BEGIN + CREATE TABLE {_table_ref(self._memory_table)} ( + id NVARCHAR(128) NOT NULL, + session_id NVARCHAR(128) NOT NULL, + app_name NVARCHAR(128) NOT NULL, + user_id NVARCHAR(128) NOT NULL, + scope NVARCHAR(16) NOT NULL CONSTRAINT {_constraint_ref("df", self._memory_table, "scope")} DEFAULT N'user', + event_id NVARCHAR(128) NOT NULL, + author NVARCHAR(256) NULL, + timestamp DATETIME2(6) NOT NULL, + content_json NVARCHAR(MAX) NOT NULL, + content_text NVARCHAR(MAX) NOT NULL, + metadata_json NVARCHAR(MAX) NULL, + inserted_at DATETIME2(6) NOT NULL CONSTRAINT {_constraint_ref("df", self._memory_table, "inserted_at")} + DEFAULT SYSUTCDATETIME(){owner_line}, + CONSTRAINT {_constraint_ref("pk", self._memory_table, "id")} PRIMARY KEY (id), + CONSTRAINT {_constraint_ref("uq", self._memory_table, "event_id")} UNIQUE (event_id) + ); +END; +""" + + def _memory_index_specs(self) -> "list[tuple[str, str, str]]": + """Return ``(index_name, table, columns)`` specs for memory-table indexes.""" + return [ + ( + f"idx_{self._memory_table}_app_scope_user_time", + self._memory_table, + "app_name, scope, user_id, timestamp DESC", + ), + (f"idx_{self._memory_table}_scope", self._memory_table, "app_name, scope"), + (f"idx_{self._memory_table}_session", self._memory_table, "session_id"), + (f"idx_{self._memory_table}_timestamp", self._memory_table, "timestamp DESC"), + ] + + def _drop_memory_table_sql(self) -> "list[str]": + return [f"DROP TABLE IF EXISTS {_table_ref(self._memory_table)}"] + + def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: + cursor.execute(sql, params) + return list(cursor.fetchall()) + + def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: + with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: + cursor.execute(sql, params) + rowcount = _cursor_rowcount(cursor) + if commit: + conn.commit() + return rowcount + + def _adk_config(config: Any) -> MssqlPythonADKConfig: extension_config = getattr(config, "extension_config", {}) if not isinstance(extension_config, dict): @@ -428,9 +604,7 @@ def _adk_config(config: Any) -> MssqlPythonADKConfig: def _configured_json_column_type(native_json: "bool | None") -> "str | None": if native_json is True: return JSON_NATIVE_COLUMN_TYPE - if native_json is False: - return JSON_FALLBACK_COLUMN_TYPE - return None + return JSON_FALLBACK_COLUMN_TYPE def _json_column_type_from_sync_driver(driver: "MssqlPythonDriver") -> str: @@ -648,6 +822,37 @@ def _event_record_from_row(row: Any) -> StoredEvent: ) +def _memory_record_from_row(row: Any) -> "StoredMemory": + return cast( + "StoredMemory", + { + "id": row[0], + "session_id": row[1], + "app_name": row[2], + "user_id": row[3], + "scope": row[4], + "event_id": row[5], + "author": row[6], + "timestamp": row[7], + "content_json": _json_dict(row[8]), + "content_text": row[9], + "metadata_json": _json_dict(row[10]) if row[10] is not None else None, + "inserted_at": row[11], + "embedding": None, + }, + ) + + +def _build_mssql_scope_where( + app_name: str, user_id: str, scope_filter: Literal["all", "user", "app"] +) -> "tuple[str, tuple[Any, ...]]": + if scope_filter == "all": + return "app_name = ? AND ((scope = 'user' AND user_id = ?) OR scope = 'app')", (app_name, user_id) + if scope_filter == "user": + return "app_name = ? AND scope = 'user' AND user_id = ?", (app_name, user_id) + return "app_name = ? AND scope = 'app'", (app_name,) + + def _json_dict(value: Any) -> "dict[str, Any]": if value is None: return {} diff --git a/tests/unit/adapters/test_mssql_python/test_adk_store.py b/tests/unit/adapters/test_mssql_python/test_adk_store.py index 6888d7696..de22ab815 100644 --- a/tests/unit/adapters/test_mssql_python/test_adk_store.py +++ b/tests/unit/adapters/test_mssql_python/test_adk_store.py @@ -7,9 +7,10 @@ import pytest from typing_extensions import NotRequired -from sqlspec.adapters.mssql_python.adk import MssqlPythonADKConfig, MssqlPythonADKStore +from sqlspec.adapters.mssql_python.adk import MssqlPythonADKConfig, MssqlPythonADKMemoryStore, MssqlPythonADKStore from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseSyncADKStore +from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore def _mock_config(adk_config: dict[str, object] | None = None) -> MagicMock: @@ -186,3 +187,59 @@ def test_mssql_python_list_sessions_rejects_invalid_options( store.list_sessions("app", **options) assert calls == [] + + +def test_mssql_python_config_accepts_default_adk_extension_config() -> None: + """The default ADK extension config resolves both session and memory stores.""" + from sqlspec.adapters.mssql_python import MssqlPythonConfig + + config = MssqlPythonConfig( + connection_config={"server": "localhost", "database": "app", "user": "u", "password": "p"}, + extension_config={"adk": {}}, + ) + + assert config.extension_config["adk"] == {} + + +def test_mssql_python_adk_memory_store_instance() -> None: + """MssqlPythonADKMemoryStore satisfies BaseSyncADKMemoryStore with default table name.""" + store = MssqlPythonADKMemoryStore(_mock_config()) + assert isinstance(store, BaseSyncADKMemoryStore) + assert store.memory_table == "adk_memory" + + +def test_mssql_python_adk_memory_store_ddl() -> None: + """Memory table DDL contains expected T-SQL column and constraint definitions.""" + store = MssqlPythonADKMemoryStore(_mock_config()) + ddl = store._memory_table_ddl() + assert "IF NOT EXISTS (SELECT 1 FROM sys.tables" in ddl + assert "content_text NVARCHAR(MAX) NOT NULL" in ddl + assert "inserted_at DATETIME2(6) NOT NULL" in ddl + assert "UNIQUE (event_id)" in ddl + + +def test_mssql_python_adk_memory_store_search_entries(monkeypatch: "pytest.MonkeyPatch") -> None: + """Search entries generates TOP (?) and LIKE ? queries with expected parameter bindings.""" + calls: list[tuple[str, tuple[Any, ...]]] = [] + + def capture(_store: MssqlPythonADKMemoryStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls.append((sql, tuple(params))) + return [] + + monkeypatch.setattr(MssqlPythonADKMemoryStore, "_execute_fetchall", capture) + store = MssqlPythonADKMemoryStore(_mock_config()) + results = store.search_entries("latte", "app", "u1", limit=5, scope_filter="user") + + assert results == [] + assert len(calls) == 1 + sql, params = calls[0] + normalized_sql = _normalized(sql) + assert normalized_sql.startswith("SELECT TOP (?)") + assert "content_text LIKE ?" in normalized_sql + assert params == (5, "app", "u1", "%latte%") + + +def test_mssql_python_adk_memory_store_drop_table_sql() -> None: + """Drop memory table statement uses T-SQL IF EXISTS syntax.""" + store = MssqlPythonADKMemoryStore(_mock_config()) + assert store._drop_memory_table_sql() == ["DROP TABLE IF EXISTS [dbo].[adk_memory]"] diff --git a/tests/unit/adapters/test_mssql_store_parity.py b/tests/unit/adapters/test_mssql_store_parity.py new file mode 100644 index 000000000..f6e1464f1 --- /dev/null +++ b/tests/unit/adapters/test_mssql_store_parity.py @@ -0,0 +1,97 @@ +# pyright: reportPrivateUsage=false +"""Cross-driver SQL parity between the mssql-python and pymssql extension stores.""" + +from typing import Any + +import pytest + +pytest.importorskip("mssql_python") +pytest.importorskip("pymssql") + +from sqlspec.adapters.mssql_python import MssqlPythonConfig +from sqlspec.adapters.mssql_python.adk import MssqlPythonADKMemoryStore, MssqlPythonADKStore +from sqlspec.adapters.mssql_python.events import MssqlPythonEventQueueStore +from sqlspec.adapters.mssql_python.litestar import MssqlPythonStore +from sqlspec.adapters.pymssql import PymssqlConfig +from sqlspec.adapters.pymssql.adk import PymssqlADKMemoryStore, PymssqlADKStore +from sqlspec.adapters.pymssql.events import PymssqlEventQueueStore +from sqlspec.adapters.pymssql.litestar import PymssqlStore + + +def _normalized(sql: str) -> str: + return " ".join(sql.split()) + + +def _qmark(sql: str) -> str: + return sql.replace("%s", "?") + + +def test_adk_session_and_event_ddl_match() -> None: + """ADK session and event table DDL and indexes match across drivers.""" + mssql_cfg = MssqlPythonConfig(extension_config={"adk": {}}) + pymssql_cfg = PymssqlConfig(extension_config={"adk": {}}) + mssql_store = MssqlPythonADKStore(mssql_cfg) + pymssql_store = PymssqlADKStore(pymssql_cfg) + + assert _normalized(mssql_store._sessions_table_ddl()) == _normalized(pymssql_store._sessions_table_ddl()) + assert _normalized(mssql_store._events_table_ddl()) == _normalized(pymssql_store._events_table_ddl()) + assert mssql_store._index_specs() == pymssql_store._index_specs() + assert mssql_store._drop_tables_sql() == pymssql_store._drop_tables_sql() + + +def test_adk_memory_ddl_and_indexes_match() -> None: + """ADK memory table DDL and indexes match across drivers.""" + mssql_cfg = MssqlPythonConfig(extension_config={"adk": {}}) + pymssql_cfg = PymssqlConfig(extension_config={"adk": {}}) + mssql_memory = MssqlPythonADKMemoryStore(mssql_cfg) + pymssql_memory = PymssqlADKMemoryStore(pymssql_cfg) + + assert _normalized(mssql_memory._memory_table_ddl()) == _normalized(pymssql_memory._memory_table_ddl()) + assert mssql_memory._memory_index_specs() == pymssql_memory._memory_index_specs() + assert mssql_memory._drop_memory_table_sql() == pymssql_memory._drop_memory_table_sql() + + +def test_adk_session_list_query_matches_modulo_placeholder(monkeypatch: "pytest.MonkeyPatch") -> None: + """ADK session list SQL matches modulo parameter placeholder style.""" + mssql_cfg = MssqlPythonConfig(extension_config={"adk": {}}) + pymssql_cfg = PymssqlConfig(extension_config={"adk": {}}) + calls: dict[str, tuple[str, tuple[Any, ...]]] = {} + + def capture_mssql(_store: Any, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls["mssql"] = (sql, tuple(params)) + return [] + + def capture_pymssql(_store: Any, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls["pymssql"] = (sql, tuple(params)) + return [] + + monkeypatch.setattr(MssqlPythonADKStore, "_execute_fetchall", capture_mssql) + monkeypatch.setattr(PymssqlADKStore, "_execute_fetchall", capture_pymssql) + MssqlPythonADKStore(mssql_cfg).list_sessions("app", "u1", limit=10, offset=20) + PymssqlADKStore(pymssql_cfg).list_sessions("app", "u1", limit=10, offset=20) + + assert _normalized(calls["mssql"][0]) == _normalized(_qmark(calls["pymssql"][0])) + assert calls["mssql"][1] == calls["pymssql"][1] + + +def test_event_queue_ddl_matches() -> None: + """Event queue DDL statements match across drivers.""" + mssql_cfg = MssqlPythonConfig(extension_config={"events": {}}) + pymssql_cfg = PymssqlConfig(extension_config={"events": {}}) + + assert ( + MssqlPythonEventQueueStore(mssql_cfg).create_statements() + == PymssqlEventQueueStore(pymssql_cfg).create_statements() + ) + assert ( + MssqlPythonEventQueueStore(mssql_cfg).drop_statements() == PymssqlEventQueueStore(pymssql_cfg).drop_statements() + ) + + +def test_litestar_store_ddl_matches() -> None: + """Litestar session store DDL matches across drivers.""" + mssql_cfg = MssqlPythonConfig(extension_config={"litestar": {}}) + pymssql_cfg = PymssqlConfig(extension_config={"litestar": {}}) + + assert _normalized(MssqlPythonStore(mssql_cfg)._table_ddl()) == _normalized(PymssqlStore(pymssql_cfg)._table_ddl()) + assert MssqlPythonStore(mssql_cfg)._drop_table_sql() == PymssqlStore(pymssql_cfg)._drop_table_sql() diff --git a/tests/unit/extensions/test_adk/test_store_instantiation.py b/tests/unit/extensions/test_adk/test_store_instantiation.py index f93944edf..30d9bd449 100644 --- a/tests/unit/extensions/test_adk/test_store_instantiation.py +++ b/tests/unit/extensions/test_adk/test_store_instantiation.py @@ -25,10 +25,12 @@ "sqlspec.adapters.aiosqlite.adk.AiosqliteADKStore", "sqlspec.adapters.asyncmy.adk.AsyncmyADKStore", "sqlspec.adapters.adbc.adk.AdbcADKStore", + "sqlspec.adapters.arrow_odbc.adk.ArrowOdbcADKStore", "sqlspec.adapters.cockroach_asyncpg.adk.CockroachAsyncpgADKStore", "sqlspec.adapters.cockroach_psycopg.adk.CockroachPsycopgAsyncADKStore", "sqlspec.adapters.cockroach_psycopg.adk.CockroachPsycopgSyncADKStore", "sqlspec.adapters.duckdb.adk.DuckdbADKStore", + "sqlspec.adapters.mssql_python.adk.MssqlPythonADKStore", "sqlspec.adapters.mysqlconnector.adk.MysqlConnectorAsyncADKStore", "sqlspec.adapters.mysqlconnector.adk.MysqlConnectorSyncADKStore", "sqlspec.adapters.oracledb.adk.OracleAsyncADKStore", @@ -36,6 +38,7 @@ "sqlspec.adapters.psqlpy.adk.PsqlpyADKStore", "sqlspec.adapters.psycopg.adk.PsycopgAsyncADKStore", "sqlspec.adapters.psycopg.adk.PsycopgSyncADKStore", + "sqlspec.adapters.pymssql.adk.PymssqlADKStore", "sqlspec.adapters.pymysql.adk.PyMysqlADKStore", "sqlspec.adapters.spanner.adk.SpannerSyncADKStore", "sqlspec.adapters.sqlite.adk.SqliteADKStore", @@ -47,10 +50,12 @@ "sqlspec.adapters.aiosqlite.adk.AiosqliteADKMemoryStore", "sqlspec.adapters.asyncmy.adk.AsyncmyADKMemoryStore", "sqlspec.adapters.adbc.adk.AdbcADKMemoryStore", + "sqlspec.adapters.arrow_odbc.adk.ArrowOdbcADKMemoryStore", "sqlspec.adapters.cockroach_asyncpg.adk.CockroachAsyncpgADKMemoryStore", "sqlspec.adapters.cockroach_psycopg.adk.CockroachPsycopgAsyncADKMemoryStore", "sqlspec.adapters.cockroach_psycopg.adk.CockroachPsycopgSyncADKMemoryStore", "sqlspec.adapters.duckdb.adk.DuckdbADKMemoryStore", + "sqlspec.adapters.mssql_python.adk.MssqlPythonADKMemoryStore", "sqlspec.adapters.mysqlconnector.adk.MysqlConnectorAsyncADKMemoryStore", "sqlspec.adapters.mysqlconnector.adk.MysqlConnectorSyncADKMemoryStore", "sqlspec.adapters.oracledb.adk.OracleAsyncADKMemoryStore", @@ -58,6 +63,7 @@ "sqlspec.adapters.psqlpy.adk.PsqlpyADKMemoryStore", "sqlspec.adapters.psycopg.adk.PsycopgAsyncADKMemoryStore", "sqlspec.adapters.psycopg.adk.PsycopgSyncADKMemoryStore", + "sqlspec.adapters.pymssql.adk.PymssqlADKMemoryStore", "sqlspec.adapters.pymysql.adk.PyMysqlADKMemoryStore", "sqlspec.adapters.spanner.adk.SpannerSyncADKMemoryStore", "sqlspec.adapters.sqlite.adk.SqliteADKMemoryStore", @@ -67,11 +73,14 @@ SYNC_SESSION_STORE_CLASSES = [ "sqlspec.adapters.adbc.adk.AdbcADKStore", + "sqlspec.adapters.arrow_odbc.adk.ArrowOdbcADKStore", "sqlspec.adapters.cockroach_psycopg.adk.CockroachPsycopgSyncADKStore", "sqlspec.adapters.duckdb.adk.DuckdbADKStore", + "sqlspec.adapters.mssql_python.adk.MssqlPythonADKStore", "sqlspec.adapters.mysqlconnector.adk.MysqlConnectorSyncADKStore", "sqlspec.adapters.oracledb.adk.OracleSyncADKStore", "sqlspec.adapters.psycopg.adk.PsycopgSyncADKStore", + "sqlspec.adapters.pymssql.adk.PymssqlADKStore", "sqlspec.adapters.pymysql.adk.PyMysqlADKStore", "sqlspec.adapters.spanner.adk.SpannerSyncADKStore", "sqlspec.adapters.sqlite.adk.SqliteADKStore", @@ -79,11 +88,14 @@ SYNC_MEMORY_STORE_CLASSES = [ "sqlspec.adapters.adbc.adk.AdbcADKMemoryStore", + "sqlspec.adapters.arrow_odbc.adk.ArrowOdbcADKMemoryStore", "sqlspec.adapters.cockroach_psycopg.adk.CockroachPsycopgSyncADKMemoryStore", "sqlspec.adapters.duckdb.adk.DuckdbADKMemoryStore", + "sqlspec.adapters.mssql_python.adk.MssqlPythonADKMemoryStore", "sqlspec.adapters.mysqlconnector.adk.MysqlConnectorSyncADKMemoryStore", "sqlspec.adapters.oracledb.adk.OracleSyncADKMemoryStore", "sqlspec.adapters.psycopg.adk.PsycopgSyncADKMemoryStore", + "sqlspec.adapters.pymssql.adk.PymssqlADKMemoryStore", "sqlspec.adapters.pymysql.adk.PyMysqlADKMemoryStore", "sqlspec.adapters.spanner.adk.SpannerSyncADKMemoryStore", "sqlspec.adapters.sqlite.adk.SqliteADKMemoryStore", From 35fe9c9d031d44d8e7d95b7ae12f3df79dd31675 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:08:58 +0000 Subject: [PATCH 02/16] test(mssql): register mssql-python and pymssql in event-queue and litestar store contracts (#778) --- .../adapters/mssql_python/litestar/store.py | 14 ++-- sqlspec/adapters/pymssql/config.py | 3 +- sqlspec/adapters/pymssql/litestar/store.py | 2 +- .../adapters/_shared/_events_cases.py | 4 + .../adapters/_shared/_store_cases.py | 7 ++ tests/integration/fixtures/__init__.py | 8 ++ tests/integration/fixtures/mssql.py | 78 ++++++++++++++++++- 7 files changed, 106 insertions(+), 10 deletions(-) diff --git a/sqlspec/adapters/mssql_python/litestar/store.py b/sqlspec/adapters/mssql_python/litestar/store.py index 4f18b40ae..1e4464f96 100644 --- a/sqlspec/adapters/mssql_python/litestar/store.py +++ b/sqlspec/adapters/mssql_python/litestar/store.py @@ -93,7 +93,7 @@ def _create_table(self) -> None: def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": sql = f""" SELECT data, expires_at FROM {self._table_name} - WHERE session_id = %s + WHERE session_id = ? AND (expires_at IS NULL OR expires_at > SYSUTCDATETIME()) """ with self._config.provide_connection() as conn: @@ -116,8 +116,8 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | update_cursor.execute( f""" UPDATE {self._table_name} - SET expires_at = %s, updated_at = SYSUTCDATETIME() - WHERE session_id = %s + SET expires_at = ?, updated_at = SYSUTCDATETIME() + WHERE session_id = ? """, (new_expires_at, key), ) @@ -132,7 +132,7 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No expires_at = self._calculate_expires_at(expires_in) sql = f""" MERGE INTO {self._table_name} AS target - USING (SELECT %s AS session_id, %s AS data, %s AS expires_at) AS src + USING (SELECT ? AS session_id, CONVERT(VARBINARY(MAX), ?) AS data, ? AS expires_at) AS src ON target.session_id = src.session_id WHEN MATCHED THEN UPDATE SET @@ -155,7 +155,7 @@ def _delete(self, key: str) -> None: with self._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(f"DELETE FROM {self._table_name} WHERE session_id = %s", (key,)) + cursor.execute(f"DELETE FROM {self._table_name} WHERE session_id = ?", (key,)) finally: cursor.close() conn.commit() @@ -174,7 +174,7 @@ def _exists(self, key: str) -> bool: sql = f""" SELECT 1 FROM {self._table_name} - WHERE session_id = %s + WHERE session_id = ? AND (expires_at IS NULL OR expires_at > SYSUTCDATETIME()) """ with self._config.provide_connection() as conn: @@ -189,7 +189,7 @@ def _expires_in(self, key: str) -> "int | None": with self._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(f"SELECT expires_at FROM {self._table_name} WHERE session_id = %s", (key,)) + cursor.execute(f"SELECT expires_at FROM {self._table_name} WHERE session_id = ?", (key,)) row = cursor.fetchone() finally: cursor.close() diff --git a/sqlspec/adapters/pymssql/config.py b/sqlspec/adapters/pymssql/config.py index 8446d9c31..d6b6d559d 100644 --- a/sqlspec/adapters/pymssql/config.py +++ b/sqlspec/adapters/pymssql/config.py @@ -179,4 +179,5 @@ def get_signature_namespace(self) -> "dict[str, Any]": return namespace def get_event_runtime_hints(self) -> "EventRuntimeHints": - return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) + """Return runtime hints for pymssql event channels.""" + return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) diff --git a/sqlspec/adapters/pymssql/litestar/store.py b/sqlspec/adapters/pymssql/litestar/store.py index c9cf0eea5..77af491f1 100644 --- a/sqlspec/adapters/pymssql/litestar/store.py +++ b/sqlspec/adapters/pymssql/litestar/store.py @@ -132,7 +132,7 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No expires_at = self._calculate_expires_at(expires_in) sql = f""" MERGE INTO {self._table_name} AS target - USING (SELECT %s AS session_id, %s AS data, %s AS expires_at) AS src + USING (SELECT %s AS session_id, CONVERT(VARBINARY(MAX), %s) AS data, %s AS expires_at) AS src ON target.session_id = src.session_id WHEN MATCHED THEN UPDATE SET diff --git a/tests/integration/adapters/_shared/_events_cases.py b/tests/integration/adapters/_shared/_events_cases.py index 4f57ad633..d0fb0005b 100644 --- a/tests/integration/adapters/_shared/_events_cases.py +++ b/tests/integration/adapters/_shared/_events_cases.py @@ -67,6 +67,10 @@ class ListenNotifyCaseContext: "sync", marks=(MSSQL_MARK, MSSQL_XDIST_MARK, ARROW_ODBC_MARK), ), + EventsCase( + "mssql-python-sync", "events_config_mssql_python", "mssql_python", "sync", marks=(MSSQL_MARK, MSSQL_XDIST_MARK) + ), + EventsCase("pymssql-sync", "events_config_pymssql", "pymssql", "sync", marks=(MSSQL_MARK, MSSQL_XDIST_MARK)), EventsCase("pymysql-sync", "events_config_pymysql", "pymysql", "sync", marks=(MYSQL_XDIST_MARK,)), EventsCase( "psycopg-sync", diff --git a/tests/integration/adapters/_shared/_store_cases.py b/tests/integration/adapters/_shared/_store_cases.py index 0b654c21f..051ef6cae 100644 --- a/tests/integration/adapters/_shared/_store_cases.py +++ b/tests/integration/adapters/_shared/_store_cases.py @@ -46,6 +46,13 @@ class StoreCaseContext: "arrow_odbc", marks=(MSSQL_MARK, MSSQL_XDIST_MARK, ARROW_ODBC_MARK, pytest.mark.anyio), ), + StoreCase( + "mssql-python", + "contract_mssql_python_store", + "mssql_python", + marks=(MSSQL_MARK, MSSQL_XDIST_MARK, pytest.mark.anyio), + ), + StoreCase("pymssql", "contract_pymssql_store", "pymssql", marks=(MSSQL_MARK, MSSQL_XDIST_MARK, pytest.mark.anyio)), StoreCase("asyncpg", "contract_asyncpg_store", "asyncpg", marks=(POSTGRES_XDIST_MARK, pytest.mark.anyio)), StoreCase("psqlpy", "contract_psqlpy_store", "psqlpy", marks=(POSTGRES_XDIST_MARK, pytest.mark.anyio)), StoreCase( diff --git a/tests/integration/fixtures/__init__.py b/tests/integration/fixtures/__init__.py index 251904d26..0b39096a2 100644 --- a/tests/integration/fixtures/__init__.py +++ b/tests/integration/fixtures/__init__.py @@ -26,6 +26,10 @@ from tests.integration.fixtures.duckdb import duckdb_basic_config, duckdb_basic_session from tests.integration.fixtures.mssql import ( arrow_odbc_mssql_config, + contract_mssql_python_store, + contract_pymssql_store, + events_config_mssql_python, + events_config_pymssql, mssql_migration_connection_config, mssql_python_config, mssql_python_connection_config, @@ -143,8 +147,12 @@ "cockroach_asyncpg_config", "cockroach_psycopg_async_config", "cockroach_psycopg_sync_config", + "contract_mssql_python_store", + "contract_pymssql_store", "duckdb_basic_config", "duckdb_basic_session", + "events_config_mssql_python", + "events_config_pymssql", "gizmosql_sqlite_service", "mssql_migration_connection_config", "mssql_python_config", diff --git a/tests/integration/fixtures/mssql.py b/tests/integration/fixtures/mssql.py index b2374cb5f..fe08a3d7d 100644 --- a/tests/integration/fixtures/mssql.py +++ b/tests/integration/fixtures/mssql.py @@ -1,6 +1,8 @@ """Shared SQL Server integration fixtures.""" -from collections.abc import Generator +import contextlib +from collections.abc import AsyncGenerator, Callable, Generator +from pathlib import Path from typing import Any import pytest @@ -8,13 +10,19 @@ from sqlspec.adapters.arrow_odbc import ArrowOdbcConfig from sqlspec.adapters.mssql_python import MssqlPythonConfig +from sqlspec.adapters.mssql_python.litestar import MssqlPythonStore from sqlspec.adapters.pymssql import PymssqlConfig +from sqlspec.adapters.pymssql.litestar import PymssqlStore __all__ = ( "MSSQL_MIGRATION_LOGIN", "MSSQL_MIGRATION_PASSWORD", "arrow_odbc_mssql_config", + "contract_mssql_python_store", + "contract_pymssql_store", "ensure_mssql_migration_login", + "events_config_mssql_python", + "events_config_pymssql", "mssql_migration_connection_config", "mssql_python_config", "mssql_python_connection_config", @@ -26,6 +34,18 @@ MSSQL_MIGRATION_LOGIN = "sqlspec_migrator" MSSQL_MIGRATION_PASSWORD = "Password123!" +_STORE_EXTENSION_CONFIG: "dict[str, Any]" = {"litestar": {"session_table": "litestar_contract_sessions"}} + + +def _mssql_events_migration_config(tmp_path: Path, suffix: str) -> "dict[str, Any]": + migrations = tmp_path / f"migrations_{suffix}" + migrations.mkdir() + return { + "script_location": str(migrations), + "include_extensions": ["events"], + "version_table_name": f"ddl_migrations_{suffix}", + } + def ensure_mssql_migration_login(mssql_service: "MSSQLService") -> None: """Ensure SQL Server migration login and database user exist with db_owner membership.""" @@ -128,6 +148,62 @@ def arrow_odbc_mssql_config(mssql_service: "MSSQLService") -> "Generator[ArrowOd config.close_pool() +@pytest.fixture +def events_config_mssql_python(mssql_service: "MSSQLService", tmp_path: Path) -> "Callable[..., Any]": + """Build mssql-python event-channel configs for contract tests.""" + + def make(*, extension_config: "dict[str, Any]", suffix: str) -> MssqlPythonConfig: + return MssqlPythonConfig( + connection_config=_mssql_python_connection_config(mssql_service), + migration_config=_mssql_events_migration_config(tmp_path, suffix), + extension_config=extension_config, + ) + + return make + + +@pytest.fixture +def events_config_pymssql(mssql_service: "MSSQLService", tmp_path: Path) -> "Callable[..., Any]": + """Build pymssql event-channel configs for contract tests.""" + + def make(*, extension_config: "dict[str, Any]", suffix: str) -> PymssqlConfig: + return PymssqlConfig( + connection_config=_mssql_connection_config(mssql_service), + migration_config=_mssql_events_migration_config(tmp_path, suffix), + extension_config=extension_config, + ) + + return make + + +@pytest.fixture +async def contract_mssql_python_store(mssql_service: "MSSQLService") -> "AsyncGenerator[MssqlPythonStore, None]": + """Provide a ready mssql-python Litestar store for contract tests.""" + config = MssqlPythonConfig( + connection_config=_mssql_python_connection_config(mssql_service), extension_config=_STORE_EXTENSION_CONFIG + ) + store = MssqlPythonStore(config) + await store.create_table() + yield store + with contextlib.suppress(Exception): + await store.delete_all() + config.close_pool() + + +@pytest.fixture +async def contract_pymssql_store(mssql_service: "MSSQLService") -> "AsyncGenerator[PymssqlStore, None]": + """Provide a ready pymssql Litestar store for contract tests.""" + config = PymssqlConfig( + connection_config=_mssql_connection_config(mssql_service), extension_config=_STORE_EXTENSION_CONFIG + ) + store = PymssqlStore(config) + await store.create_table() + yield store + with contextlib.suppress(Exception): + await store.delete_all() + config.close_pool() + + @pytest.fixture(scope="session") def mssql_migration_connection_config(mssql_service: "MSSQLService") -> "dict[str, Any]": """Provide SQL Server connection parameters using the dedicated migration login.""" From e604118fe6145067f4c7225e66c6cd1ba9ffa12d Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:13:03 +0000 Subject: [PATCH 03/16] test(mssql): register mssql-python and pymssql in ADK store contract matrix (#778) --- .../adapters/_shared/_adk_cases.py | 7 ++++ tests/integration/fixtures/adapter_cases.py | 37 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/integration/adapters/_shared/_adk_cases.py b/tests/integration/adapters/_shared/_adk_cases.py index e4cb5ae32..d4e1e09c4 100644 --- a/tests/integration/adapters/_shared/_adk_cases.py +++ b/tests/integration/adapters/_shared/_adk_cases.py @@ -104,6 +104,13 @@ class AdkStoreCaseContext: "arrow_odbc", marks=(MSSQL_MARK, MSSQL_XDIST_MARK, ARROW_ODBC_MARK, pytest.mark.anyio), ), + AdkStoreCase( + "mssql-python", + "adk_store_mssql_python", + "mssql_python", + marks=(MSSQL_MARK, MSSQL_XDIST_MARK, pytest.mark.anyio), + ), + AdkStoreCase("pymssql", "adk_store_pymssql", "pymssql", marks=(MSSQL_MARK, MSSQL_XDIST_MARK, pytest.mark.anyio)), ) ADK_STORE_PARAMS = tuple(pytest.param(case, id=case.id, marks=case.marks) for case in ADK_STORE_CASES) diff --git a/tests/integration/fixtures/adapter_cases.py b/tests/integration/fixtures/adapter_cases.py index 9323f1d55..42757d705 100644 --- a/tests/integration/fixtures/adapter_cases.py +++ b/tests/integration/fixtures/adapter_cases.py @@ -52,6 +52,7 @@ from sqlspec.adapters.duckdb.adk import DuckdbADKStore from sqlspec.adapters.duckdb.litestar import DuckdbStore from sqlspec.adapters.mssql_python import MssqlPythonConfig, MssqlPythonDriver +from sqlspec.adapters.mssql_python.adk import MssqlPythonADKStore from sqlspec.adapters.mysqlconnector import ( MysqlConnectorAsyncConfig, MysqlConnectorAsyncDriver, @@ -83,6 +84,7 @@ from sqlspec.adapters.psycopg.adk import PsycopgAsyncADKStore, PsycopgSyncADKStore from sqlspec.adapters.psycopg.litestar import PsycopgAsyncStore, PsycopgSyncStore from sqlspec.adapters.pymssql import PymssqlConfig, PymssqlDriver +from sqlspec.adapters.pymssql.adk import PymssqlADKStore from sqlspec.adapters.pymysql import PyMysqlConfig, PyMysqlDriver, PyMysqlDriverFeatures from sqlspec.adapters.pymysql.adk import PyMysqlADKStore from sqlspec.adapters.pymysql.litestar import PyMysqlStore @@ -130,7 +132,11 @@ from tests.integration.adapters._shared._store_cases import STORE_PARAMS, StoreCase, StoreCaseContext from tests.integration.adapters.bigquery._wedge import describe_wedge, is_emulator_wedge from tests.integration.fixtures.bigquery import _bigquery_connection_config -from tests.integration.fixtures.mssql import _arrow_odbc_connection_config +from tests.integration.fixtures.mssql import ( + _arrow_odbc_connection_config, + _mssql_connection_config, + _mssql_python_connection_config, +) from tests.integration.fixtures.mysql import _mysql_connection_config from tests.integration.fixtures.oracle import _oracle_pool_params from tests.integration.fixtures.postgres import ( @@ -2158,6 +2164,35 @@ def make() -> "tuple[Any, Any]": return make +@pytest.fixture +def adk_store_mssql_python(mssql_service: MSSQLService) -> Callable[..., Any]: + """Build a fresh mssql-python ADK store with isolated tables per call.""" + + def make() -> "tuple[Any, Any]": + suffix = uuid4().hex[:8] + config = MssqlPythonConfig( + connection_config=_mssql_python_connection_config(mssql_service), + extension_config=_adk_extension_config(suffix), + ) + return config, MssqlPythonADKStore(config) + + return make + + +@pytest.fixture +def adk_store_pymssql(mssql_service: MSSQLService) -> Callable[..., Any]: + """Build a fresh pymssql ADK store with isolated tables per call.""" + + def make() -> "tuple[Any, Any]": + suffix = uuid4().hex[:8] + config = PymssqlConfig( + connection_config=_mssql_connection_config(mssql_service), extension_config=_adk_extension_config(suffix) + ) + return config, PymssqlADKStore(config) + + return make + + def _resolve_adk_store_case(request: pytest.FixtureRequest, case: AdkStoreCase) -> AdkStoreCaseContext: return AdkStoreCaseContext(case=case, make_store=request.getfixturevalue(case.factory_fixture)) From f27634536df5e3facd30d41316c41f76f56f3816 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:19:54 +0000 Subject: [PATCH 04/16] docs(mssql): add SQL Server recipes page (#778) --- docs/recipes/index.rst | 7 + docs/recipes/sql_server.rst | 453 ++++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 docs/recipes/sql_server.rst diff --git a/docs/recipes/index.rst b/docs/recipes/index.rst index cfc870535..b4c1d8f73 100644 --- a/docs/recipes/index.rst +++ b/docs/recipes/index.rst @@ -25,9 +25,16 @@ patterns. Base service classes with pagination, get-or-404, and transaction helpers. + .. grid-item-card:: SQL Server + :link: sql_server + :link-type: doc + + Driver choice, pooling, GO batches, BulkCopy, migrations, ADK stores, the event queue, and Litestar on SQL Server. + .. toctree:: :hidden: dishka service_layer + sql_server diff --git a/docs/recipes/sql_server.rst b/docs/recipes/sql_server.rst new file mode 100644 index 000000000..0099a4838 --- /dev/null +++ b/docs/recipes/sql_server.rst @@ -0,0 +1,453 @@ +========== +SQL Server +========== + +This recipe collection demonstrates how to use Microsoft SQL Server with SQLSpec across common production patterns. +It covers driver selection, connection management, transactional workflows, and ecosystem integrations including Google ADK, durable event queues, and Litestar. + +.. note:: + + SQL Server connectivity requires either the ``sqlspec[mssql-python]`` or ``sqlspec[pymssql]`` installation extra. + +Choosing a Driver +================= + +SQLSpec offers three drivers for Microsoft SQL Server depending on workload requirements and platform dependencies: + +.. list-table:: + :header-rows: 1 + :widths: 20 25 25 30 + + * - Driver + - Parameter style + - Bulk load + - Notes + * - ``mssql-python`` + - qmark (``?``) + - Native BulkCopy and Arrow + - Microsoft's official driver; fast C bindings, native Arrow and BulkCopy. + * - ``pymssql`` + - qmark (``?``) / pyformat (``%(name)s``) + - ``execute_many`` only + - FreeTDS-based wrapper; multi-platform, pure-Python fallback. + * - ``arrow-odbc`` + - qmark (``?``) + - Arrow-first ODBC streaming + - Microsoft ODBC Driver 18; columnar data processing and analytics. + +.. code-block:: python + + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.adapters.pymssql import PymssqlConfig + + mssql_config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + pymssql_config = PymssqlConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + +Connecting and Pooling +====================== + +Configurations define connection parameters and built-in connection pool settings. +The ``provide_session()`` context manager acquires a driver instance from the pool, and ``close_pool()`` cleanly releases all pool resources. + +.. code-block:: python + + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.adapters.pymssql import PymssqlConfig + + mssql_config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + "encrypt": False, + "trust_server_certificate": True, + "pool_size": 10, + "pool_idle_timeout": 30.0, + "pool_enabled": True, + } + ) + + pymssql_config = PymssqlConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + "charset": "utf8", + "pool_recycle_seconds": 3600, + } + ) + + with mssql_config.provide_session() as driver: + result = driver.execute("SELECT 1 AS ready") + rows = result.fetchall() + + mssql_config.close_pool() + pymssql_config.close_pool() + +Parameter Styles +================ + +The ``mssql-python`` adapter strictly uses positional question-mark (``?``) placeholders. +The ``pymssql`` adapter accepts question-mark (``?``) placeholders as well as named pyformat placeholders (``%(name)s``). +Positional and named parameter styles must never be mixed within the same query. + +.. code-block:: python + + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.adapters.pymssql import PymssqlConfig + + mssql_config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + pymssql_config = PymssqlConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + + with mssql_config.provide_session() as driver: + user = driver.execute( + "SELECT id, username, email FROM users WHERE id = ?", + (42,), + ).fetchone() + + with pymssql_config.provide_session() as driver: + user_qmark = driver.execute( + "SELECT id, username, email FROM users WHERE id = ?", + (42,), + ).fetchone() + + user_named = driver.execute( + "SELECT id, username, email FROM users WHERE username = %(name)s", + {"name": "ada"}, + ).fetchone() + +Running Multi-Batch Scripts with GO +=================================== + +SQL Server scripts and DDL statements often use the ``GO`` batch separator. +The ``execute_script`` method splits input text on ``GO`` separators and runs each statement batch in sequence. + +.. code-block:: python + + from sqlspec.adapters.mssql_python import MssqlPythonConfig + + config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + + script = """ + CREATE TABLE dbo.audit_log ( + id INT IDENTITY(1,1) PRIMARY KEY, + event_type NVARCHAR(50) NOT NULL, + created_at DATETIME2(6) NOT NULL DEFAULT SYSUTCDATETIME() + ) + GO + CREATE NONCLUSTERED INDEX ix_audit_log_event_type ON dbo.audit_log (event_type) + GO + """ + + with config.provide_session() as driver: + driver.execute_script(script) + +Transactions and Savepoints +=========================== + +Transactions are managed using ``begin()``, ``commit()``, and ``rollback()``. +When a connection is initialized with ``autocommit=True``, committing or rolling back a transaction automatically restores the autocommit state. +Named savepoints are supported through ``create_savepoint()``, ``release_savepoint()``, and ``rollback_to_savepoint()``. + +.. code-block:: python + + from sqlspec.adapters.mssql_python import MssqlPythonConfig + + config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + "autocommit": True, + } + ) + + with config.provide_session() as driver: + driver.begin() + try: + driver.execute( + "INSERT INTO accounts (id, balance) VALUES (?, ?)", + (1, 1000), + ) + driver.create_savepoint("s1") + driver.execute( + "INSERT INTO audit_trail (account_id, action) VALUES (?, ?)", + (1, "deposit_pending"), + ) + driver.rollback_to_savepoint("s1") + driver.commit() + except Exception: + driver.rollback() + raise + +Bulk Loading with BulkCopy and Arrow +==================================== + +The ``mssql-python`` adapter provides high-throughput bulk insertion via Microsoft BulkCopy and Apache Arrow streaming. +In contrast, ``pymssql`` does not support native BulkCopy and uses batched ``execute_many`` operations. + +.. code-block:: python + + import pyarrow as pa + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.adapters.pymssql import PymssqlConfig + + mssql_config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + pymssql_config = PymssqlConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + } + ) + + rows = [ + (1, "login", "2026-01-01T00:00:00"), + (2, "logout", "2026-01-01T00:01:00"), + ] + + arrow_table = pa.Table.from_arrays( + [ + pa.array([1, 2]), + pa.array(["login", "logout"]), + pa.array(["2026-01-01T00:00:00", "2026-01-01T00:01:00"]), + ], + names=["id", "event_type", "created_at"], + ) + + with mssql_config.provide_session() as driver: + driver.bulk_copy( + "dbo.events", + rows, + batch_size=10000, + table_lock=True, + ) + driver.load_from_arrow("dbo.events", arrow_table) + + with pymssql_config.provide_session() as driver: + driver.execute_many( + "INSERT INTO dbo.events (id, event_type, created_at) VALUES (?, ?, ?)", + rows, + ) + +Migrations +========== + +Database migrations are executed with ``SyncMigrationCommands``. +SQL Server resolves unqualified table references against the login user's default schema (typically ``dbo``), unless configured otherwise. + +.. code-block:: python + + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.migrations.commands import SyncMigrationCommands + + config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + }, + migration_config={ + "script_location": "migrations", + "version_table_name": "schema_migrations", + }, + ) + + commands = SyncMigrationCommands(config) + commands.upgrade() + +Google ADK Session and Memory Stores +==================================== + +SQL Server supports Google ADK session storage via ``MssqlPythonADKStore`` and ``PymssqlADKStore``, as well as memory storage via ``MssqlPythonADKMemoryStore`` and ``PymssqlADKMemoryStore``. +Setting ``native_json: False`` configures ``NVARCHAR(MAX)`` columns for JSON payloads. +Call ``ensure_tables()`` to provision tables. +Because both SQL Server drivers are synchronous, asynchronous ADK runners should wrap store operations using ``anyio.to_thread.run_sync``. + +.. code-block:: python + + import anyio + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.adapters.mssql_python.adk import ( + MssqlPythonADKMemoryStore, + MssqlPythonADKStore, + ) + + config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + }, + extension_config={"adk": {"native_json": False}}, + ) + + session_store = MssqlPythonADKStore(config) + memory_store = MssqlPythonADKMemoryStore(config) + + session_store.ensure_tables() + memory_store.ensure_tables() + + + async def run_agent() -> None: + session = await anyio.to_thread.run_sync( + session_store.get_session, + "session-123", + ) + +Durable Event Queue +=================== + +SQLSpec provides a transactional event queue backed by a SQL Server table. +The queue table is created by running migrations with ``"include_extensions": ["events"]``. +The channel publishes, consumes, and acknowledges events using the ``poll_queue`` strategy. + +.. code-block:: python + + from sqlspec import SQLSpec + from sqlspec.adapters.mssql_python import MssqlPythonConfig + from sqlspec.migrations.commands import SyncMigrationCommands + + config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + }, + extension_config={"events": {"queue_table": "app_events"}}, + migration_config={ + "script_location": "migrations", + "version_table_name": "schema_migrations", + "include_extensions": ["events"], + }, + ) + + SyncMigrationCommands(config).upgrade() + + spec = SQLSpec() + spec.add_config(config) + channel = spec.event_channel(config) + + channel.publish("notifications", {"user_id": 42, "event": "order_placed"}) + + event = channel.consume("notifications") + if event is not None: + channel.ack(event.id) + +Litestar Plugin and Session Store +================================= + +The Litestar integration enables dependency injection of ``MssqlPythonDriver`` or ``PymssqlDriver`` into route handlers via ``SQLSpecPlugin``. +Server-side session storage is provided by ``MssqlPythonStore`` and ``PymssqlStore`` for use with Litestar's ``SessionMiddleware``. + +.. code-block:: python + + from litestar import Litestar, get + from litestar.middleware.session.server_side import ServerSideSessionConfig + from sqlspec import SQLSpec + from sqlspec.adapters.mssql_python import MssqlPythonConfig, MssqlPythonDriver + from sqlspec.adapters.mssql_python.litestar import MssqlPythonStore + from sqlspec.extensions.litestar import SQLSpecPlugin + + config = MssqlPythonConfig( + connection_config={ + "server": "localhost", + "port": 1433, + "database": "app_db", + "user": "sa", + "password": "SecretPassword123!", + }, + extension_config={"litestar": {"session_table": "app_sessions"}}, + ) + + spec = SQLSpec() + spec.add_config(config) + plugin = SQLSpecPlugin(sqlspec=spec) + + session_store = MssqlPythonStore(config) + session_config = ServerSideSessionConfig() + + + @get("/users") + def get_users(db_session: MssqlPythonDriver) -> list[dict[str, object]]: + return db_session.execute("SELECT id, username FROM users").fetchall() + + + app = Litestar( + route_handlers=[get_users], + plugins=[plugin], + middleware=[session_config.middleware], + stores={"sessions": session_store}, + ) + +See Also +======== + +* :doc:`dishka` +* :doc:`/reference/adapters/mssql_python` +* :doc:`/reference/adapters/pymssql` +* :doc:`/reference/adapters/arrow_odbc` +* :doc:`/usage/bulk_ingest` +* :doc:`/extensions/adk/index` From 4956bf53e9b0b649124b364153bb0b58700198f0 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:24:16 +0000 Subject: [PATCH 05/16] docs(mssql): document mssql extension stores and update feature table (#778) --- docs/changelog.rst | 14 +++++++++++ docs/extensions/adk/adapters.rst | 4 +-- docs/extensions/adk/backends.rst | 23 +++++++++++++++++ docs/reference/adapters/index.rst | 4 +-- docs/reference/adapters/mssql_python.rst | 32 ++++++++++++++++++++---- docs/reference/adapters/pymssql.rst | 21 ++++++++++++++++ docs/reference/extensions/events.rst | 7 +++--- 7 files changed, 93 insertions(+), 12 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 078016b28..cb3ddada1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,13 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param (defaults to ``True``; set to ``False`` for ADBC and arrow-odbc). (`#773 `_) +* SQL Server extension stores and documentation alignment: + added :class:`~sqlspec.adapters.mssql_python.adk.MssqlPythonADKMemoryStore` to complete + ADK store parity across SQL Server adapters, added an end-to-end SQL Server recipes guide + (:doc:`/recipes/sql_server`), and registered ``mssql_python`` and ``pymssql`` across + shared integration test suites for Google ADK, durable event queues, and Litestar session stores. + (`#781 `_) + * Sync and async drivers provide :meth:`~sqlspec.driver.SyncDriverAdapterBase.transaction`, a context manager that begins a transaction, commits when the block succeeds, and rolls back and re-raises when it fails; a failed commit is followed by a rollback attempt. A block entered while @@ -176,6 +183,13 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param ``SerializationConflictError``, and all other transaction failures to ``OperationalError``. (`#775 `_) +* Fixed SQL Server Litestar session store parameter placeholder and binary conversion in + ``MssqlPythonStore`` and ``PymssqlStore`` (``CONVERT(VARBINARY(MAX), ?)``), removed unsupported + ``FOR UPDATE`` hints from ``PymssqlConfig.get_event_runtime_hints()`` for event queue polling, + and corrected the documentation feature table and metadata to designate ``mssql-python`` as + a sync-only driver with native Arrow support. + (`#781 `_) + * Query builder keeps ``ON CONFLICT ... DO UPDATE`` and ``ON DUPLICATE KEY UPDATE`` assignments in written order. Assignments such as ``do_update(name=exp.column("name", table="excluded"))`` no longer render reversed. Conflict targets and update columns are quoted, allowing reserved words (e.g. ``order``, ``group``) diff --git a/docs/extensions/adk/adapters.rst b/docs/extensions/adk/adapters.rst index 01c3f7b60..febbbb03e 100644 --- a/docs/extensions/adk/adapters.rst +++ b/docs/extensions/adk/adapters.rst @@ -18,10 +18,10 @@ Use async adapters for best performance with ADK runners: - **DuckDB**: ``duckdb`` (analytics; reduced-scope for ADK) - **ADBC**: ``adbc`` (Arrow-native portability; reduced-scope for ADK) - **Spanner**: ``spanner`` (Google Cloud, globally distributed) -- **SQL Server over ODBC**: ``arrow_odbc`` with Microsoft ODBC Driver 18 +- **SQL Server**: ``mssql_python`` (native Arrow and BulkCopy), ``pymssql`` (FreeTDS), ``arrow_odbc`` (Arrow over Microsoft ODBC Driver 18) Sync adapters (``psycopg`` sync mode, ``sqlite``, ``mysqlconnector``, -``pymysql``, ``arrow_odbc``) work but require wrapping with ``anyio`` for async +``pymysql``, ``mssql_python``, ``pymssql``, ``arrow_odbc``) work but require wrapping with ``anyio`` for async ADK runners. Each Adapter Provides diff --git a/docs/extensions/adk/backends.rst b/docs/extensions/adk/backends.rst index 67ef9cd6b..e7100fe21 100644 --- a/docs/extensions/adk/backends.rst +++ b/docs/extensions/adk/backends.rst @@ -93,6 +93,16 @@ The table below classifies every backend by its ADK support level. - Full - Basic - Portability layer; native adapters provide optimized search. + * - mssql_python + - Supported + - Full + - Basic + - SQL Server through Microsoft's mssql-python driver; ``LIKE`` memory search. + * - pymssql + - Supported + - Full + - Basic + - SQL Server through FreeTDS; ``LIKE`` memory search. * - arrow_odbc - Supported - Full @@ -285,6 +295,19 @@ ADBC (Arrow Database Connectivity) provides a driver-agnostic interface: - Memory search uses the portable baseline path; choose a native adapter for backend-specific FTS, retention, and storage tuning. +SQL Server (mssql-python and pymssql) +------------------------------------- + +``mssql_python`` and ``pymssql`` provide SQL Server-backed ADK storage: + +- Session and event storage use SQL Server tables with ``DATETIME2(6)`` and + ``NVARCHAR(MAX)`` JSON payload columns, with an opt-in ``native_json`` switch + to use native ``JSON`` columns. +- ``append_event_and_update_state()`` commits the session update, event row, and + scoped state in one transaction. +- Memory search is ``LIKE``-based across memory entries. +- Both stores are synchronous and require wrapping with ``anyio`` for async ADK runners. + arrow-odbc ---------- diff --git a/docs/reference/adapters/index.rst b/docs/reference/adapters/index.rst index 2da7d90be..9620fb969 100644 --- a/docs/reference/adapters/index.rst +++ b/docs/reference/adapters/index.rst @@ -113,7 +113,7 @@ exports a typed config class and a driver implementation. :link: mssql_python :link-type: doc - Sync SQL Server via Microsoft's official mssql-python driver. + Sync SQL Server via Microsoft's official mssql-python driver with native Arrow and BulkCopy. .. grid-item-card:: pymssql :link: pymssql @@ -262,7 +262,7 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - Yes - - + - Yes - - Yes * - pymssql diff --git a/docs/reference/adapters/mssql_python.rst b/docs/reference/adapters/mssql_python.rst index 8ef40c4ca..831743910 100644 --- a/docs/reference/adapters/mssql_python.rst +++ b/docs/reference/adapters/mssql_python.rst @@ -2,11 +2,14 @@ mssql-python ============ -Sync SQL Server adapter built on Microsoft's official -`mssql-python `_ driver. Ships a -T-SQL data dictionary, a Litestar session store, an events queue store, -and migrations tracker. The SQL splitter also gains ``GO`` batch-separator -handling so multi-batch T-SQL scripts execute correctly. +Sync-only SQL Server adapter built on Microsoft's official +`mssql-python `_ driver. Ships +native Arrow reads and BulkCopy, a T-SQL data dictionary, migrations tracker, +events queue store, Litestar session store, and ADK session and memory stores. +The SQL splitter provides ``GO`` batch-separator handling so multi-batch +T-SQL scripts execute correctly. + +See :doc:`/recipes/sql_server` for end-to-end examples. Configuration ============= @@ -63,3 +66,22 @@ Migrations .. autoclass:: sqlspec.adapters.mssql_python.MssqlPythonSyncMigrationTracker :members: :show-inheritance: + +Extensions +========== + +.. autoclass:: sqlspec.adapters.mssql_python.events.MssqlPythonEventQueueStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.mssql_python.litestar.MssqlPythonStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.mssql_python.adk.MssqlPythonADKStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.mssql_python.adk.MssqlPythonADKMemoryStore + :members: + :show-inheritance: diff --git a/docs/reference/adapters/pymssql.rst b/docs/reference/adapters/pymssql.rst index 797bea1b2..7e482b0d4 100644 --- a/docs/reference/adapters/pymssql.rst +++ b/docs/reference/adapters/pymssql.rst @@ -7,6 +7,8 @@ and FreeTDS. It uses pyformat parameters (``%s`` and ``%(name)s``) and exposes sync SQLSpec config, driver, pooling, data dictionary, migration, and extension store integrations. +See :doc:`/recipes/sql_server` for end-to-end examples. + Configuration ============= @@ -62,3 +64,22 @@ Migrations .. autoclass:: sqlspec.adapters.pymssql.migrations.PymssqlSyncMigrationTracker :members: :show-inheritance: + +Extensions +========== + +.. autoclass:: sqlspec.adapters.pymssql.events.PymssqlEventQueueStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.pymssql.litestar.PymssqlStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.pymssql.adk.PymssqlADKStore + :members: + :show-inheritance: + +.. autoclass:: sqlspec.adapters.pymssql.adk.PymssqlADKMemoryStore + :members: + :show-inheritance: diff --git a/docs/reference/extensions/events.rst b/docs/reference/extensions/events.rst index d948a5968..269a01531 100644 --- a/docs/reference/extensions/events.rst +++ b/docs/reference/extensions/events.rst @@ -295,9 +295,10 @@ Listeners Event Queue =========== -The durable table queue is available for SQL Server through ``arrow_odbc`` when -configured with Microsoft ODBC Driver 18. It uses SQL Server ``DATETIME2(6)`` -timestamps and ``NVARCHAR`` payload columns. +The durable table queue is available for SQL Server through ``mssql_python``, +``pymssql``, and ``arrow_odbc`` (the last configured with Microsoft ODBC Driver 18). +All three use SQL Server ``DATETIME2(6)`` timestamps and ``NVARCHAR`` payload +columns, and every SQL Server backend is ``poll_queue``. Durable queue migrations reconcile missing tables and additive columns from the adapter store's canonical DDL. Set ``events.manage_schema=False`` when an From d1d449be9708cfc654bd8f57752810b0e0cd1795 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:36:04 +0000 Subject: [PATCH 06/16] test(mssql): isolate litestar store table lifecycle across sql server drivers (#778) --- tests/integration/fixtures/adapter_cases.py | 14 +++++++++++++ tests/integration/fixtures/mssql.py | 23 ++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/adapter_cases.py b/tests/integration/fixtures/adapter_cases.py index 42757d705..0d35dc16d 100644 --- a/tests/integration/fixtures/adapter_cases.py +++ b/tests/integration/fixtures/adapter_cases.py @@ -1773,10 +1773,24 @@ async def contract_arrow_odbc_store(mssql_service: MSSQLService) -> "AsyncGenera driver_features={"dbms_name": "Microsoft SQL Server"}, ) store = ArrowOdbcStore(config) + with contextlib.suppress(Exception): + with config.provide_session() as driver: + driver.execute_script( + f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}; " + f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}_chunks', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}_chunks;" + ) + driver.commit() await store.create_table() yield store with contextlib.suppress(Exception): await store.delete_all() + with contextlib.suppress(Exception): + with config.provide_session() as driver: + driver.execute_script( + f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}; " + f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}_chunks', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}_chunks;" + ) + driver.commit() config.close_pool() diff --git a/tests/integration/fixtures/mssql.py b/tests/integration/fixtures/mssql.py index fe08a3d7d..c23b9be67 100644 --- a/tests/integration/fixtures/mssql.py +++ b/tests/integration/fixtures/mssql.py @@ -34,7 +34,8 @@ MSSQL_MIGRATION_LOGIN = "sqlspec_migrator" MSSQL_MIGRATION_PASSWORD = "Password123!" -_STORE_EXTENSION_CONFIG: "dict[str, Any]" = {"litestar": {"session_table": "litestar_contract_sessions"}} +_STORE_TABLE = "litestar_contract_sessions" +_STORE_EXTENSION_CONFIG: "dict[str, Any]" = {"litestar": {"session_table": _STORE_TABLE}} def _mssql_events_migration_config(tmp_path: Path, suffix: str) -> "dict[str, Any]": @@ -183,10 +184,20 @@ async def contract_mssql_python_store(mssql_service: "MSSQLService") -> "AsyncGe connection_config=_mssql_python_connection_config(mssql_service), extension_config=_STORE_EXTENSION_CONFIG ) store = MssqlPythonStore(config) + with contextlib.suppress(Exception): + with config.provide_connection() as conn: + with conn.cursor() as cur: + cur.execute(f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}") + conn.commit() await store.create_table() yield store with contextlib.suppress(Exception): await store.delete_all() + with contextlib.suppress(Exception): + with config.provide_connection() as conn: + with conn.cursor() as cur: + cur.execute(f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}") + conn.commit() config.close_pool() @@ -197,10 +208,20 @@ async def contract_pymssql_store(mssql_service: "MSSQLService") -> "AsyncGenerat connection_config=_mssql_connection_config(mssql_service), extension_config=_STORE_EXTENSION_CONFIG ) store = PymssqlStore(config) + with contextlib.suppress(Exception): + with config.provide_connection() as conn: + with conn.cursor() as cur: + cur.execute(f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}") + conn.commit() await store.create_table() yield store with contextlib.suppress(Exception): await store.delete_all() + with contextlib.suppress(Exception): + with config.provide_connection() as conn: + with conn.cursor() as cur: + cur.execute(f"IF OBJECT_ID(N'dbo.{_STORE_TABLE}', N'U') IS NOT NULL DROP TABLE dbo.{_STORE_TABLE}") + conn.commit() config.close_pool() From c3e9656e9518fef8bb1ba18381e055fa82940baa Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:44:57 +0000 Subject: [PATCH 07/16] fix: preserve MSSQL ADK upgrades and correct SQL Server recipes --- docs/recipes/sql_server.rst | 51 +++++++++++++------ sqlspec/adapters/mssql_python/adk/store.py | 4 +- .../0002_create_mssql_python_memory.py | 51 +++++++++++++++++++ .../test_mssql_python/test_adk_store.py | 14 +++++ .../unit/adapters/test_mssql_store_parity.py | 6 +-- .../test_adk/test_mssql_memory_migration.py | 35 +++++++++++++ 6 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py create mode 100644 tests/unit/extensions/test_adk/test_mssql_memory_migration.py diff --git a/docs/recipes/sql_server.rst b/docs/recipes/sql_server.rst index 0099a4838..b1a24e288 100644 --- a/docs/recipes/sql_server.rst +++ b/docs/recipes/sql_server.rst @@ -25,11 +25,11 @@ SQLSpec offers three drivers for Microsoft SQL Server depending on workload requ * - ``mssql-python`` - qmark (``?``) - Native BulkCopy and Arrow - - Microsoft's official driver; fast C bindings, native Arrow and BulkCopy. + - Microsoft's driver with BulkCopy and SQLSpec Arrow loading. * - ``pymssql`` - qmark (``?``) / pyformat (``%(name)s``) - ``execute_many`` only - - FreeTDS-based wrapper; multi-platform, pure-Python fallback. + - FreeTDS-based driver. * - ``arrow-odbc`` - qmark (``?``) - Arrow-first ODBC streaming @@ -80,7 +80,7 @@ The ``provide_session()`` context manager acquires a driver instance from the po "encrypt": False, "trust_server_certificate": True, "pool_size": 10, - "pool_idle_timeout": 30.0, + "pool_idle_timeout": 30, "pool_enabled": True, } ) @@ -98,8 +98,7 @@ The ``provide_session()`` context manager acquires a driver instance from the po ) with mssql_config.provide_session() as driver: - result = driver.execute("SELECT 1 AS ready") - rows = result.fetchall() + rows = driver.select("SELECT 1 AS ready") mssql_config.close_pool() pymssql_config.close_pool() @@ -136,21 +135,21 @@ Positional and named parameter styles must never be mixed within the same query. ) with mssql_config.provide_session() as driver: - user = driver.execute( + user = driver.select_one_or_none( "SELECT id, username, email FROM users WHERE id = ?", (42,), - ).fetchone() + ) with pymssql_config.provide_session() as driver: - user_qmark = driver.execute( + user_qmark = driver.select_one_or_none( "SELECT id, username, email FROM users WHERE id = ?", (42,), - ).fetchone() + ) - user_named = driver.execute( + user_named = driver.select_one_or_none( "SELECT id, username, email FROM users WHERE username = %(name)s", {"name": "ada"}, - ).fetchone() + ) Running Multi-Batch Scripts with GO =================================== @@ -185,6 +184,7 @@ The ``execute_script`` method splits input text on ``GO`` separators and runs ea with config.provide_session() as driver: driver.execute_script(script) + driver.commit() Transactions and Savepoints =========================== @@ -232,6 +232,9 @@ Bulk Loading with BulkCopy and Arrow The ``mssql-python`` adapter provides high-throughput bulk insertion via Microsoft BulkCopy and Apache Arrow streaming. In contrast, ``pymssql`` does not support native BulkCopy and uses batched ``execute_many`` operations. +The three examples below use separate, pre-created tables with ``id``, +``event_type``, and ``created_at`` columns. + .. code-block:: python import pyarrow as pa @@ -278,13 +281,19 @@ In contrast, ``pymssql`` does not support native BulkCopy and uses batched ``exe batch_size=10000, table_lock=True, ) - driver.load_from_arrow("dbo.events", arrow_table) + driver.commit() + + # Arrow loading is an alternative to bulk_copy; use a separate target. + with mssql_config.provide_session() as driver: + driver.load_from_arrow("dbo.events_arrow", arrow_table) + driver.commit() with pymssql_config.provide_session() as driver: driver.execute_many( - "INSERT INTO dbo.events (id, event_type, created_at) VALUES (?, ?, ?)", + "INSERT INTO dbo.events_pymssql (id, event_type, created_at) VALUES (?, ?, ?)", rows, ) + driver.commit() Migrations ========== @@ -319,7 +328,10 @@ Google ADK Session and Memory Stores SQL Server supports Google ADK session storage via ``MssqlPythonADKStore`` and ``PymssqlADKStore``, as well as memory storage via ``MssqlPythonADKMemoryStore`` and ``PymssqlADKMemoryStore``. Setting ``native_json: False`` configures ``NVARCHAR(MAX)`` columns for JSON payloads. -Call ``ensure_tables()`` to provision tables. +Call ``ensure_tables()`` to provision tables. Existing migration-managed +installations should run the ADK extension upgrade: migration ``0002`` creates +the newly supported mssql-python memory table and indexes if missing. Downgrading +that repair preserves memory data; a full downgrade of ``0001`` removes it. Because both SQL Server drivers are synchronous, asynchronous ADK runners should wrap store operations using ``anyio.to_thread.run_sync``. .. code-block:: python @@ -352,6 +364,8 @@ Because both SQL Server drivers are synchronous, asynchronous ADK runners should async def run_agent() -> None: session = await anyio.to_thread.run_sync( session_store.get_session, + "app", + "user-123", "session-123", ) @@ -430,14 +444,19 @@ Server-side session storage is provided by ``MssqlPythonStore`` and ``PymssqlSto session_config = ServerSideSessionConfig() - @get("/users") + async def prepare_sessions() -> None: + await session_store.create_table() + + + @get("/users", sync_to_thread=True) def get_users(db_session: MssqlPythonDriver) -> list[dict[str, object]]: - return db_session.execute("SELECT id, username FROM users").fetchall() + return db_session.select("SELECT id, username FROM users") app = Litestar( route_handlers=[get_users], plugins=[plugin], + on_startup=[prepare_sessions], middleware=[session_config.middleware], stores={"sessions": session_store}, ) diff --git a/sqlspec/adapters/mssql_python/adk/store.py b/sqlspec/adapters/mssql_python/adk/store.py index 4011778fa..62d85664b 100644 --- a/sqlspec/adapters/mssql_python/adk/store.py +++ b/sqlspec/adapters/mssql_python/adk/store.py @@ -604,7 +604,9 @@ def _adk_config(config: Any) -> MssqlPythonADKConfig: def _configured_json_column_type(native_json: "bool | None") -> "str | None": if native_json is True: return JSON_NATIVE_COLUMN_TYPE - return JSON_FALLBACK_COLUMN_TYPE + if native_json is False: + return JSON_FALLBACK_COLUMN_TYPE + return None def _json_column_type_from_sync_driver(driver: "MssqlPythonDriver") -> str: diff --git a/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py b/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py new file mode 100644 index 000000000..48088760d --- /dev/null +++ b/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py @@ -0,0 +1,51 @@ +"""Provision memory storage for existing mssql-python ADK installations. + +Migration 0001 may already be applied without the newly supported memory store. +The table and index guards also make this safe after a fresh 0001 installation. +""" + +from typing import TYPE_CHECKING + +from sqlspec.adapters.mssql_python import MssqlPythonConfig +from sqlspec.adapters.mssql_python.adk.store import ( + MssqlPythonADKMemoryStore, + _create_index_sql, + _escape_sql_literal, + _table_ref, +) +from sqlspec.exceptions import SQLSpecError +from sqlspec.extensions.adk._config_utils import _adk_memory_migration_enabled + +if TYPE_CHECKING: + from sqlspec.migrations.context import MigrationContext + +__all__ = ("down", "up") + + +async def up(context: "MigrationContext | None" = None) -> list[str]: + """Create missing mssql-python memory storage and its lookup indexes.""" + if context is None or context.config is None: + msg = "Migration context must have a config to determine store class" + raise SQLSpecError(msg) + config = context.config + if not isinstance(config, MssqlPythonConfig) or not _adk_memory_migration_enabled(config): + return [] + store = MssqlPythonADKMemoryStore(config) + statements = [store._memory_table_ddl()] # pyright: ignore[reportPrivateUsage] + for index_name, table, columns in store._memory_index_specs(): # pyright: ignore[reportPrivateUsage] + statements.append( + "IF NOT EXISTS (SELECT 1 FROM sys.indexes " + f"WHERE name = N'{_escape_sql_literal(index_name)}' " + f"AND object_id = OBJECT_ID(N'{_escape_sql_literal(_table_ref(table))}')) " + f"BEGIN {_create_index_sql(table, index_name, columns)}; END;" + ) + return statements + + +async def down(context: "MigrationContext | None" = None) -> list[str]: + """Preserve memory data, which may have been created before this migration. + + Migration 0001 owns removal during a full ADK teardown. This additive repair + cannot distinguish an existing table from one it provisioned. + """ + return [] diff --git a/tests/unit/adapters/test_mssql_python/test_adk_store.py b/tests/unit/adapters/test_mssql_python/test_adk_store.py index de22ab815..a7cc3413f 100644 --- a/tests/unit/adapters/test_mssql_python/test_adk_store.py +++ b/tests/unit/adapters/test_mssql_python/test_adk_store.py @@ -243,3 +243,17 @@ def test_mssql_python_adk_memory_store_drop_table_sql() -> None: """Drop memory table statement uses T-SQL IF EXISTS syntax.""" store = MssqlPythonADKMemoryStore(_mock_config()) assert store._drop_memory_table_sql() == ["DROP TABLE IF EXISTS [dbo].[adk_memory]"] + + +@pytest.mark.parametrize(("major", "expected"), [(16, "NVARCHAR(MAX)"), (17, "JSON")]) +def test_sync_store_detects_json_support_by_default(major: int, expected: str) -> None: + """Omitting native_json preserves server-version detection.""" + from sqlspec.adapters.mssql_python.data_dictionary import MssqlVersionInfo + + config = _mock_config() + driver = config.provide_session.return_value.__enter__.return_value + driver.data_dictionary.get_version.return_value = MssqlVersionInfo(major=major) + store = MssqlPythonADKStore(config) + + assert f"state {expected} NOT NULL" in store._sessions_table_ddl() + driver.data_dictionary.get_version.assert_called_once_with(driver) diff --git a/tests/unit/adapters/test_mssql_store_parity.py b/tests/unit/adapters/test_mssql_store_parity.py index f6e1464f1..e8c117fa4 100644 --- a/tests/unit/adapters/test_mssql_store_parity.py +++ b/tests/unit/adapters/test_mssql_store_parity.py @@ -28,7 +28,7 @@ def _qmark(sql: str) -> str: def test_adk_session_and_event_ddl_match() -> None: """ADK session and event table DDL and indexes match across drivers.""" - mssql_cfg = MssqlPythonConfig(extension_config={"adk": {}}) + mssql_cfg = MssqlPythonConfig(extension_config={"adk": {"native_json": False}}) pymssql_cfg = PymssqlConfig(extension_config={"adk": {}}) mssql_store = MssqlPythonADKStore(mssql_cfg) pymssql_store = PymssqlADKStore(pymssql_cfg) @@ -41,7 +41,7 @@ def test_adk_session_and_event_ddl_match() -> None: def test_adk_memory_ddl_and_indexes_match() -> None: """ADK memory table DDL and indexes match across drivers.""" - mssql_cfg = MssqlPythonConfig(extension_config={"adk": {}}) + mssql_cfg = MssqlPythonConfig(extension_config={"adk": {"native_json": False}}) pymssql_cfg = PymssqlConfig(extension_config={"adk": {}}) mssql_memory = MssqlPythonADKMemoryStore(mssql_cfg) pymssql_memory = PymssqlADKMemoryStore(pymssql_cfg) @@ -53,7 +53,7 @@ def test_adk_memory_ddl_and_indexes_match() -> None: def test_adk_session_list_query_matches_modulo_placeholder(monkeypatch: "pytest.MonkeyPatch") -> None: """ADK session list SQL matches modulo parameter placeholder style.""" - mssql_cfg = MssqlPythonConfig(extension_config={"adk": {}}) + mssql_cfg = MssqlPythonConfig(extension_config={"adk": {"native_json": False}}) pymssql_cfg = PymssqlConfig(extension_config={"adk": {}}) calls: dict[str, tuple[str, tuple[Any, ...]]] = {} diff --git a/tests/unit/extensions/test_adk/test_mssql_memory_migration.py b/tests/unit/extensions/test_adk/test_mssql_memory_migration.py new file mode 100644 index 000000000..38b404547 --- /dev/null +++ b/tests/unit/extensions/test_adk/test_mssql_memory_migration.py @@ -0,0 +1,35 @@ +"""Upgrade coverage for installations that applied ADK 0001 before memory support.""" + +import importlib + +import pytest + +from sqlspec.adapters.mssql_python import MssqlPythonConfig +from sqlspec.adapters.sqlite import SqliteConfig +from sqlspec.migrations.context import MigrationContext + +migration = importlib.import_module("sqlspec.extensions.adk.migrations.0002_create_mssql_python_memory") + + +@pytest.mark.anyio +async def test_existing_mssql_installation_receives_memory_table_and_indexes() -> None: + config = MssqlPythonConfig(extension_config={"adk": {"memory_table": "custom_memory"}}) + statements = await migration.up(MigrationContext(config=config)) + + assert len(statements) == 5 + assert "CREATE TABLE [dbo].[custom_memory]" in statements[0] + assert all("IF NOT EXISTS" in statement for statement in statements) + assert all("OBJECT_ID(N'[dbo].[custom_memory]')" in statement for statement in statements[1:]) + assert await migration.down(MigrationContext(config=config)) == [] + + +@pytest.mark.anyio +async def test_disabled_mssql_memory_migration_does_not_create_tables() -> None: + config = MssqlPythonConfig(extension_config={"adk": {"enable_memory": False}}) + assert await migration.up(MigrationContext(config=config)) == [] + + +@pytest.mark.anyio +async def test_other_adapters_are_unchanged_by_mssql_memory_upgrade() -> None: + config = SqliteConfig(connection_config={"database": ":memory:"}, extension_config={"adk": {}}) + assert await migration.up(MigrationContext(config=config)) == [] From f65a04862b96692cf48e523858d79dd028884f2b Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:46:07 +0000 Subject: [PATCH 08/16] fix: keep SQL Server migration dependencies optional --- docs/extensions/adk/backends.rst | 5 +++-- docs/reference/adapters/index.rst | 2 +- .../0002_create_mssql_python_memory.py | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/extensions/adk/backends.rst b/docs/extensions/adk/backends.rst index e7100fe21..330b38488 100644 --- a/docs/extensions/adk/backends.rst +++ b/docs/extensions/adk/backends.rst @@ -301,8 +301,9 @@ SQL Server (mssql-python and pymssql) ``mssql_python`` and ``pymssql`` provide SQL Server-backed ADK storage: - Session and event storage use SQL Server tables with ``DATETIME2(6)`` and - ``NVARCHAR(MAX)`` JSON payload columns, with an opt-in ``native_json`` switch - to use native ``JSON`` columns. + JSON payload columns. ``mssql_python`` detects native ``JSON`` support from + the server version; ``pymssql`` defaults to ``NVARCHAR(MAX)``. Set + ``native_json`` explicitly to select native or fallback storage. - ``append_event_and_update_state()`` commits the session update, event row, and scoped state in one transaction. - Memory search is ``LIKE``-based across memory entries. diff --git a/docs/reference/adapters/index.rst b/docs/reference/adapters/index.rst index 9620fb969..3021e0c9e 100644 --- a/docs/reference/adapters/index.rst +++ b/docs/reference/adapters/index.rst @@ -113,7 +113,7 @@ exports a typed config class and a driver implementation. :link: mssql_python :link-type: doc - Sync SQL Server via Microsoft's official mssql-python driver with native Arrow and BulkCopy. + Sync SQL Server via Microsoft's official mssql-python driver with Arrow loading and BulkCopy. .. grid-item-card:: pymssql :link: pymssql diff --git a/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py b/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py index 48088760d..2c5727415 100644 --- a/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py +++ b/sqlspec/extensions/adk/migrations/0002_create_mssql_python_memory.py @@ -6,13 +6,6 @@ from typing import TYPE_CHECKING -from sqlspec.adapters.mssql_python import MssqlPythonConfig -from sqlspec.adapters.mssql_python.adk.store import ( - MssqlPythonADKMemoryStore, - _create_index_sql, - _escape_sql_literal, - _table_ref, -) from sqlspec.exceptions import SQLSpecError from sqlspec.extensions.adk._config_utils import _adk_memory_migration_enabled @@ -28,8 +21,18 @@ async def up(context: "MigrationContext | None" = None) -> list[str]: msg = "Migration context must have a config to determine store class" raise SQLSpecError(msg) config = context.config - if not isinstance(config, MssqlPythonConfig) or not _adk_memory_migration_enabled(config): + if not _adk_memory_migration_enabled(config): return [] + if not any(cls.__module__.startswith("sqlspec.adapters.mssql_python.") for cls in type(config).__mro__): + return [] + + from sqlspec.adapters.mssql_python.adk.store import ( + MssqlPythonADKMemoryStore, + _create_index_sql, + _escape_sql_literal, + _table_ref, + ) + store = MssqlPythonADKMemoryStore(config) statements = [store._memory_table_ddl()] # pyright: ignore[reportPrivateUsage] for index_name, table, columns in store._memory_index_specs(): # pyright: ignore[reportPrivateUsage] From e8148b19b6282f58dcbd4c98e20716adf31a3364 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:47:11 +0000 Subject: [PATCH 09/16] docs: record SQL Server memory upgrade and correct PR links --- docs/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cb3ddada1..43faf3465 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,8 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param ADK store parity across SQL Server adapters, added an end-to-end SQL Server recipes guide (:doc:`/recipes/sql_server`), and registered ``mssql_python`` and ``pymssql`` across shared integration test suites for Google ADK, durable event queues, and Litestar session stores. + ADK migration ``0002`` provisions missing mssql-python memory tables and lookup indexes + for existing installations; downgrading that additive repair preserves memory data. (`#781 `_) * Sync and async drivers provide :meth:`~sqlspec.driver.SyncDriverAdapterBase.transaction`, a context manager that begins a @@ -187,7 +189,7 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param ``MssqlPythonStore`` and ``PymssqlStore`` (``CONVERT(VARBINARY(MAX), ?)``), removed unsupported ``FOR UPDATE`` hints from ``PymssqlConfig.get_event_runtime_hints()`` for event queue polling, and corrected the documentation feature table and metadata to designate ``mssql-python`` as - a sync-only driver with native Arrow support. + a sync-only driver with Arrow support. (`#781 `_) * Query builder keeps ``ON CONFLICT ... DO UPDATE`` and ``ON DUPLICATE KEY UPDATE`` assignments in written From 3b70d444a1baf365e1ff6e30b112dd0c24a85ca3 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:49:11 +0000 Subject: [PATCH 10/16] fix: align SQL Server defaults and recipes with driver support --- docs/changelog.rst | 2 ++ docs/extensions/adk/backends.rst | 8 +++-- docs/recipes/sql_server.rst | 27 ++++++++++++----- sqlspec/adapters/mssql_python/adk/store.py | 29 ++----------------- .../test_mssql_python/test_adk_store.py | 12 ++++---- 5 files changed, 34 insertions(+), 44 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 43faf3465..449228d83 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,6 +36,8 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param shared integration test suites for Google ADK, durable event queues, and Litestar session stores. ADK migration ``0002`` provisions missing mssql-python memory tables and lookup indexes for existing installations; downgrading that additive repair preserves memory data. + mssql-python ADK JSON storage defaults to driver-supported ``NVARCHAR(MAX)``; + the explicit ``native_json=True`` override remains available. (`#781 `_) * Sync and async drivers provide :meth:`~sqlspec.driver.SyncDriverAdapterBase.transaction`, a context manager that begins a diff --git a/docs/extensions/adk/backends.rst b/docs/extensions/adk/backends.rst index 330b38488..3dadeb74d 100644 --- a/docs/extensions/adk/backends.rst +++ b/docs/extensions/adk/backends.rst @@ -301,9 +301,11 @@ SQL Server (mssql-python and pymssql) ``mssql_python`` and ``pymssql`` provide SQL Server-backed ADK storage: - Session and event storage use SQL Server tables with ``DATETIME2(6)`` and - JSON payload columns. ``mssql_python`` detects native ``JSON`` support from - the server version; ``pymssql`` defaults to ``NVARCHAR(MAX)``. Set - ``native_json`` explicitly to select native or fallback storage. + ``NVARCHAR(MAX)`` JSON payload columns. Microsoft's + `type mapping reference `_ + lists native ``JSON`` as unsupported, even when the server supports the type. + The existing ``native_json=True`` override remains available for deployments + that have independently verified driver support. - ``append_event_and_update_state()`` commits the session update, event row, and scoped state in one transaction. - Memory search is ``LIKE``-based across memory entries. diff --git a/docs/recipes/sql_server.rst b/docs/recipes/sql_server.rst index b1a24e288..273008386 100644 --- a/docs/recipes/sql_server.rst +++ b/docs/recipes/sql_server.rst @@ -229,14 +229,21 @@ Named savepoints are supported through ``create_savepoint()``, ``release_savepoi Bulk Loading with BulkCopy and Arrow ==================================== -The ``mssql-python`` adapter provides high-throughput bulk insertion via Microsoft BulkCopy and Apache Arrow streaming. +The ``mssql-python`` adapter provides bulk insertion through Microsoft BulkCopy. +SQLSpec's ``load_from_arrow()`` currently materializes Arrow data as Python rows +and passes them to BulkCopy. In contrast, ``pymssql`` does not support native BulkCopy and uses batched ``execute_many`` operations. The three examples below use separate, pre-created tables with ``id``, -``event_type``, and ``created_at`` columns. +``event_type``, and ``created_at`` columns. Commit table creation before loading. +BulkCopy uses its own connection and commits independently: rolling back the +SQLSpec session cannot undo a completed load. See Microsoft's +`BulkCopy documentation `_. .. code-block:: python + from datetime import datetime + import pyarrow as pa from sqlspec.adapters.mssql_python import MssqlPythonConfig from sqlspec.adapters.pymssql import PymssqlConfig @@ -261,15 +268,15 @@ The three examples below use separate, pre-created tables with ``id``, ) rows = [ - (1, "login", "2026-01-01T00:00:00"), - (2, "logout", "2026-01-01T00:01:00"), + (1, "login", datetime(2026, 1, 1)), + (2, "logout", datetime(2026, 1, 1, 0, 1)), ] arrow_table = pa.Table.from_arrays( [ pa.array([1, 2]), pa.array(["login", "logout"]), - pa.array(["2026-01-01T00:00:00", "2026-01-01T00:01:00"]), + pa.array([datetime(2026, 1, 1), datetime(2026, 1, 1, 0, 1)]), ], names=["id", "event_type", "created_at"], ) @@ -406,9 +413,13 @@ The channel publishes, consumes, and acknowledges events using the ``poll_queue` channel.publish("notifications", {"user_id": 42, "event": "order_placed"}) - event = channel.consume("notifications") - if event is not None: - channel.ack(event.id) + try: + for event in channel.iter_events("notifications"): + channel.ack(event.event_id) + break + finally: + channel.shutdown() + config.close_pool() Litestar Plugin and Session Store ================================= diff --git a/sqlspec/adapters/mssql_python/adk/store.py b/sqlspec/adapters/mssql_python/adk/store.py index 62d85664b..5eb864503 100644 --- a/sqlspec/adapters/mssql_python/adk/store.py +++ b/sqlspec/adapters/mssql_python/adk/store.py @@ -7,7 +7,6 @@ from typing_extensions import NotRequired from sqlspec.adapters.mssql_python._typing import MSSQL_PYTHON_MODULE, MssqlPythonCursor -from sqlspec.adapters.mssql_python.data_dictionary import MssqlVersionInfo from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore @@ -45,14 +44,13 @@ class MssqlPythonADKStore(BaseSyncADKStore["MssqlPythonConfig"]): """Synchronous mssql-python ADK session/event store.""" connector_name: ClassVar[str] = "mssql_python" - __slots__ = ("_json_column_type", "_native_json") + __slots__ = ("_json_column_type",) def __init__(self, config: "MssqlPythonConfig") -> None: super().__init__(config) adk_config = _adk_config(config) native_json = adk_config.get("native_json") - self._native_json: bool | None = native_json if isinstance(native_json, bool) else None - self._json_column_type: str | None = None + self._json_column_type = JSON_NATIVE_COLUMN_TYPE if native_json is True else JSON_FALLBACK_COLUMN_TYPE def create_tables(self) -> None: """Create ADK tables (idempotent T-SQL) and DD-gated indexes.""" @@ -386,14 +384,6 @@ def _events_query( return _events_query(self._events_table, app_name, user_id, session_id, after_timestamp, limit) def _json_column_type_sync(self) -> str: - if self._json_column_type is not None: - return self._json_column_type - configured = _configured_json_column_type(self._native_json) - if configured is not None: - self._json_column_type = configured - return configured - with self._config.provide_session() as driver: - self._json_column_type = _json_column_type_from_sync_driver(driver) return self._json_column_type def _execute_fetchone(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> "Any | None": @@ -601,21 +591,6 @@ def _adk_config(config: Any) -> MssqlPythonADKConfig: return cast("MssqlPythonADKConfig", adk_config) -def _configured_json_column_type(native_json: "bool | None") -> "str | None": - if native_json is True: - return JSON_NATIVE_COLUMN_TYPE - if native_json is False: - return JSON_FALLBACK_COLUMN_TYPE - return None - - -def _json_column_type_from_sync_driver(driver: "MssqlPythonDriver") -> str: - version_info = driver.data_dictionary.get_version(driver) - if isinstance(version_info, MssqlVersionInfo) and version_info.supports_native_json(): - return JSON_NATIVE_COLUMN_TYPE - return JSON_FALLBACK_COLUMN_TYPE - - def _sessions_table_ddl(table: str, json_column_type: str, owner_id_column_ddl: "str | None") -> str: owner_line = f",\n {owner_id_column_ddl}" if owner_id_column_ddl else "" return f""" diff --git a/tests/unit/adapters/test_mssql_python/test_adk_store.py b/tests/unit/adapters/test_mssql_python/test_adk_store.py index a7cc3413f..c6b2cd8d1 100644 --- a/tests/unit/adapters/test_mssql_python/test_adk_store.py +++ b/tests/unit/adapters/test_mssql_python/test_adk_store.py @@ -82,7 +82,7 @@ def test_sync_store_generates_tsql_idempotent_schema_with_conservative_json() -> def test_sync_store_can_force_native_json_from_extension_config() -> None: - """MSSQL-native JSON is opt-in unless version detection proves support.""" + """MSSQL-native JSON requires an explicit override.""" store = MssqlPythonADKStore(_mock_config({"native_json": True})) @@ -245,9 +245,9 @@ def test_mssql_python_adk_memory_store_drop_table_sql() -> None: assert store._drop_memory_table_sql() == ["DROP TABLE IF EXISTS [dbo].[adk_memory]"] -@pytest.mark.parametrize(("major", "expected"), [(16, "NVARCHAR(MAX)"), (17, "JSON")]) -def test_sync_store_detects_json_support_by_default(major: int, expected: str) -> None: - """Omitting native_json preserves server-version detection.""" +@pytest.mark.parametrize("major", [16, 17]) +def test_sync_store_defaults_to_driver_supported_json_storage(major: int) -> None: + """Server JSON availability does not imply native driver JSON support.""" from sqlspec.adapters.mssql_python.data_dictionary import MssqlVersionInfo config = _mock_config() @@ -255,5 +255,5 @@ def test_sync_store_detects_json_support_by_default(major: int, expected: str) - driver.data_dictionary.get_version.return_value = MssqlVersionInfo(major=major) store = MssqlPythonADKStore(config) - assert f"state {expected} NOT NULL" in store._sessions_table_ddl() - driver.data_dictionary.get_version.assert_called_once_with(driver) + assert "state NVARCHAR(MAX) NOT NULL" in store._sessions_table_ddl() + config.provide_session.assert_not_called() From c44e3fcd3f022d250d42476f0184b16de5ad877e Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:49:35 +0000 Subject: [PATCH 11/16] chore: remove obsolete MSSQL driver type import --- sqlspec/adapters/mssql_python/adk/store.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sqlspec/adapters/mssql_python/adk/store.py b/sqlspec/adapters/mssql_python/adk/store.py index 5eb864503..10c095565 100644 --- a/sqlspec/adapters/mssql_python/adk/store.py +++ b/sqlspec/adapters/mssql_python/adk/store.py @@ -17,7 +17,6 @@ from datetime import timedelta from sqlspec.adapters.mssql_python.config import MssqlPythonConfig - from sqlspec.adapters.mssql_python.driver import MssqlPythonDriver from sqlspec.extensions.adk import SessionOrderBy from sqlspec.extensions.adk.memory._types import StoredMemory From 6efb47dabe73dca3e11903695d00194415d336e9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:52:56 +0000 Subject: [PATCH 12/16] fix: serialize concurrent SQL Server memory deduplication --- docs/changelog.rst | 3 +- sqlspec/adapters/mssql_python/adk/store.py | 21 ++--- sqlspec/adapters/pymssql/adk/store.py | 21 ++--- .../mssql/test_memory_deduplication.py | 79 +++++++++++++++++++ 4 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 tests/integration/adapters/mssql/test_memory_deduplication.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 449228d83..373110253 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -191,7 +191,8 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param ``MssqlPythonStore`` and ``PymssqlStore`` (``CONVERT(VARBINARY(MAX), ?)``), removed unsupported ``FOR UPDATE`` hints from ``PymssqlConfig.get_event_runtime_hints()`` for event queue polling, and corrected the documentation feature table and metadata to designate ``mssql-python`` as - a sync-only driver with Arrow support. + a sync-only driver with Arrow support. SQL Server ADK memory inserts now + deduplicate concurrent event IDs with a single key-range-locked statement. (`#781 `_) * Query builder keeps ``ON CONFLICT ... DO UPDATE`` and ``ON DUPLICATE KEY UPDATE`` assignments in written diff --git a/sqlspec/adapters/mssql_python/adk/store.py b/sqlspec/adapters/mssql_python/adk/store.py index 10c095565..67a433ac6 100644 --- a/sqlspec/adapters/mssql_python/adk/store.py +++ b/sqlspec/adapters/mssql_python/adk/store.py @@ -443,21 +443,22 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" owner_value = ", ?" if self._owner_id_column_name else "" + # Keep the key-range lock and insertion in one statement, including autocommit. sql = f""" - IF NOT EXISTS (SELECT 1 FROM {_table_ref(self._memory_table)} WHERE event_id = ?) - BEGIN - INSERT INTO {_table_ref(self._memory_table)} ( - id, session_id, app_name, user_id, scope, event_id, author, timestamp, - content_json, content_text, metadata_json{owner_column} - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{owner_value}); - END; + INSERT INTO {_table_ref(self._memory_table)} ( + id, session_id, app_name, user_id, scope, event_id, author, timestamp, + content_json, content_text, metadata_json{owner_column} + ) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{owner_value} + WHERE NOT EXISTS ( + SELECT 1 FROM {_table_ref(self._memory_table)} WITH (UPDLOCK, HOLDLOCK) + WHERE event_id = ? + ); """ inserted = 0 with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: for entry in entries: params: tuple[Any, ...] = ( - entry["event_id"], entry["id"], entry["session_id"], entry["app_name"], @@ -472,7 +473,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object ) if self._owner_id_column_name: params = (*params, owner_id) - cursor.execute(sql, params) + cursor.execute(sql, (*params, entry["event_id"])) inserted += _cursor_rowcount(cursor) conn.commit() return inserted diff --git a/sqlspec/adapters/pymssql/adk/store.py b/sqlspec/adapters/pymssql/adk/store.py index eb428a778..2f363e529 100644 --- a/sqlspec/adapters/pymssql/adk/store.py +++ b/sqlspec/adapters/pymssql/adk/store.py @@ -454,21 +454,22 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" owner_value = ", %s" if self._owner_id_column_name else "" + # Keep the key-range lock and insertion in one statement, including autocommit. sql = f""" - IF NOT EXISTS (SELECT 1 FROM {_table_ref(self._memory_table)} WHERE event_id = %s) - BEGIN - INSERT INTO {_table_ref(self._memory_table)} ( - id, session_id, app_name, user_id, scope, event_id, author, timestamp, - content_json, content_text, metadata_json{owner_column} - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s{owner_value}); - END; + INSERT INTO {_table_ref(self._memory_table)} ( + id, session_id, app_name, user_id, scope, event_id, author, timestamp, + content_json, content_text, metadata_json{owner_column} + ) + SELECT %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s{owner_value} + WHERE NOT EXISTS ( + SELECT 1 FROM {_table_ref(self._memory_table)} WITH (UPDLOCK, HOLDLOCK) + WHERE event_id = %s + ); """ inserted = 0 with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: for entry in entries: params: tuple[Any, ...] = ( - entry["event_id"], entry["id"], entry["session_id"], entry["app_name"], @@ -483,7 +484,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object ) if self._owner_id_column_name: params = (*params, owner_id) - cursor.execute(sql, params) + cursor.execute(sql, (*params, entry["event_id"])) inserted += _cursor_rowcount(cursor) conn.commit() return inserted diff --git a/tests/integration/adapters/mssql/test_memory_deduplication.py b/tests/integration/adapters/mssql/test_memory_deduplication.py new file mode 100644 index 000000000..30de274cd --- /dev/null +++ b/tests/integration/adapters/mssql/test_memory_deduplication.py @@ -0,0 +1,79 @@ +"""SQL Server key-range locking for concurrent ADK memory deduplication.""" + +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from threading import Barrier +from uuid import uuid4 + +import pytest +from pytest_databases.docker.mssql import MSSQLService + +from sqlspec.adapters.mssql_python import MssqlPythonConfig +from sqlspec.adapters.mssql_python.adk import MssqlPythonADKMemoryStore +from sqlspec.adapters.pymssql import PymssqlConfig +from sqlspec.adapters.pymssql.adk import PymssqlADKMemoryStore +from sqlspec.extensions.adk import StoredMemory +from tests.integration.fixtures.mssql import _mssql_connection_config, _mssql_python_connection_config + +pytestmark = [pytest.mark.mssql, pytest.mark.xdist_group("mssql")] + + +@pytest.mark.parametrize("adapter", ["mssql_python", "pymssql"]) +@pytest.mark.parametrize("autocommit", [False, True]) +def test_concurrent_memory_inserts_deduplicate_event_ids( + mssql_service: MSSQLService, adapter: str, autocommit: bool +) -> None: + table = f"memory_dedup_{uuid4().hex[:8]}" + settings = {"memory_table": table, "owner_id_column": "owner_id VARCHAR(64)"} + config: MssqlPythonConfig | PymssqlConfig + store: MssqlPythonADKMemoryStore | PymssqlADKMemoryStore + if adapter == "mssql_python": + config = MssqlPythonConfig( + connection_config=_mssql_python_connection_config(mssql_service, autocommit=autocommit), + extension_config={"adk": settings}, + ) + store = MssqlPythonADKMemoryStore(config) + else: + config = PymssqlConfig( + connection_config={**_mssql_connection_config(mssql_service), "autocommit": autocommit}, + extension_config={"adk": settings}, + ) + store = PymssqlADKMemoryStore(config) + + barrier = Barrier(2) + event_id = uuid4().hex + + def insert() -> int: + entry = StoredMemory( + id=uuid4().hex, + session_id="session", + app_name="app", + user_id="user", + scope="user", + event_id=event_id, + author="user", + timestamp=datetime(2026, 1, 1), + content_json={"text": "deduplicated"}, + content_text="deduplicated", + metadata_json=None, + inserted_at=datetime(2026, 1, 1), + embedding=None, + ) + barrier.wait(timeout=10) + return store.insert_memory_entries([entry], owner_id="owner") + + try: + store.create_tables() + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(insert) for _ in range(2)] + assert sorted(future.result(timeout=30) for future in futures) == [0, 1] + entries = store.search_entries("deduplicated", "app", "user") + assert len(entries) == 1 + assert entries[0]["event_id"] == event_id + with config.provide_session() as driver: + assert driver.select_value(f"SELECT owner_id FROM [{table}]") == "owner" + finally: + try: + store.drop_tables() + finally: + config.close_pool() From 6fc7a84fd309842fd135845c36a76a185a340b06 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 18:41:06 +0000 Subject: [PATCH 13/16] fix(mssql): render standard TIMESTAMP as DATETIME2 on T-SQL DDL --- sqlspec/builder/_ddl.py | 6 +++++- tests/unit/builder/test_ddl_builder.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sqlspec/builder/_ddl.py b/sqlspec/builder/_ddl.py index 9742e078f..99293bc1c 100644 --- a/sqlspec/builder/_ddl.py +++ b/sqlspec/builder/_ddl.py @@ -77,8 +77,12 @@ def _parse_column_type(name: str | None, dtype: str, dialect: "DialectType | None") -> exp.DataType: + target_dialect = dialect + norm_dialect = _normalize_dialect(dialect) if dialect else None + if norm_dialect in ("tsql", "mssql") and dtype.strip().upper().startswith("TIMESTAMP"): + target_dialect = None try: - return exp.DataType.build(dtype, dialect=dialect) + return exp.DataType.build(dtype, dialect=target_dialect) except ParseError as exc: msg = f"Column {name!r}: cannot parse type {dtype!r} for dialect {dialect!r}" raise SQLBuilderError(msg) from exc diff --git a/tests/unit/builder/test_ddl_builder.py b/tests/unit/builder/test_ddl_builder.py index 4a476153a..06be3aa75 100644 --- a/tests/unit/builder/test_ddl_builder.py +++ b/tests/unit/builder/test_ddl_builder.py @@ -209,3 +209,11 @@ def test_ddl_accepts_public_dialect_aliases(dialect: str, dtype: str) -> None: builder = sql.create_table("t").column("a", dtype) assert "CREATE TABLE" in builder.build(dialect=dialect).sql assert "CREATE TABLE" in builder.to_statement(StatementConfig(dialect=dialect)).sql + + +def test_create_table_timestamp_on_tsql_renders_datetime2() -> None: + """Standard TIMESTAMP logical column type renders as DATETIME2 on T-SQL instead of ROWVERSION.""" + builder = sql.create_table("t").column("applied_at", "TIMESTAMP", default="CURRENT_TIMESTAMP") + sql_text = builder.build(dialect="tsql").sql + assert "DATETIME2" in sql_text + assert "ROWVERSION" not in sql_text From cf4740ae11db6f4b0c00ce6e2a3c40b896a52e4b Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 19:27:37 +0000 Subject: [PATCH 14/16] test(mssql): cover ADK memory lifecycle and migration guards --- .../mssql/test_memory_deduplication.py | 61 +++++++++++++++++++ .../test_mssql_python/test_adk_store.py | 12 ++++ .../test_adk/test_mssql_memory_migration.py | 8 +++ 3 files changed, 81 insertions(+) diff --git a/tests/integration/adapters/mssql/test_memory_deduplication.py b/tests/integration/adapters/mssql/test_memory_deduplication.py index 30de274cd..5fe58597f 100644 --- a/tests/integration/adapters/mssql/test_memory_deduplication.py +++ b/tests/integration/adapters/mssql/test_memory_deduplication.py @@ -77,3 +77,64 @@ def insert() -> int: store.drop_tables() finally: config.close_pool() + + +def test_memory_search_scopes_limits_and_retention(mssql_service: MSSQLService) -> None: + table = f"memory_lifecycle_{uuid4().hex[:8]}" + config = MssqlPythonConfig( + connection_config=_mssql_python_connection_config(mssql_service), + extension_config={"adk": {"memory_table": table, "memory_max_results": 2}}, + ) + store = MssqlPythonADKMemoryStore(config) + entries = [ + StoredMemory( + id=str(index), + session_id=f"session-{index}", + app_name=app, + user_id=user, + scope=scope, + event_id=f"event-{index}", + author="user", + timestamp=datetime(2026, 1, index + 1), + content_json={"text": "searchable", "index": index}, + content_text="searchable", + metadata_json={"source": "regression"}, + inserted_at=datetime(2026, 1, 1), + embedding=None, + ) + for index, (app, user, scope) in enumerate([ + ("app", "user", "user"), + ("app", "other", "user"), + ("app", "other", "app"), + ("other", "user", "app"), + ]) + ] + try: + store.create_tables() + store.create_tables() + assert store.insert_memory_entries([]) == 0 + assert store.insert_memory_entries(entries) == 4 + assert store.insert_memory_entries(entries) == 0 + visible = store.search_entries("searchable", "app", "user") + assert [entry["id"] for entry in visible] == ["2", "0"] + assert visible[0]["content_json"] == entries[2]["content_json"] + assert visible[0]["metadata_json"] == {"source": "regression"} + assert [entry["id"] for entry in store.search_entries("searchable", "app", "user", limit=1)] == ["2"] + assert [entry["id"] for entry in store.search_entries("searchable", "app", "user", scope_filter="user")] == [ + "0" + ] + assert [entry["id"] for entry in store.search_entries("searchable", "app", "user", scope_filter="app")] == ["2"] + assert store.search_entries("missing", "app", "user") == [] + with config.provide_session() as driver: + driver.execute(f"UPDATE [{table}] SET inserted_at = DATEADD(day, -60, SYSUTCDATETIME())") + driver.commit() + assert store.delete_entries_older_than(30, app_name="app", scope="app") == 1 + assert store.delete_entries_by_session("session-0") == 1 + assert store.delete_entries_by_session("session-0") == 0 + assert store.delete_entries_older_than(30) == 2 + assert store.search_entries("searchable", "app", "user") == [] + finally: + try: + store.drop_tables() + finally: + config.close_pool() diff --git a/tests/unit/adapters/test_mssql_python/test_adk_store.py b/tests/unit/adapters/test_mssql_python/test_adk_store.py index c6b2cd8d1..20b5cc9bf 100644 --- a/tests/unit/adapters/test_mssql_python/test_adk_store.py +++ b/tests/unit/adapters/test_mssql_python/test_adk_store.py @@ -257,3 +257,15 @@ def test_sync_store_defaults_to_driver_supported_json_storage(major: int) -> Non assert "state NVARCHAR(MAX) NOT NULL" in store._sessions_table_ddl() config.provide_session.assert_not_called() + + +def test_disabled_memory_store_rejects_operations_without_connecting() -> None: + config = _mock_config({"enable_memory": False}) + store = MssqlPythonADKMemoryStore(config) + store.create_tables() + with pytest.raises(RuntimeError, match="disabled"): + store.insert_memory_entries([]) + with pytest.raises(RuntimeError, match="disabled"): + store.search_entries("query", "app", "user") + config.provide_connection.assert_not_called() + config.provide_session.assert_not_called() diff --git a/tests/unit/extensions/test_adk/test_mssql_memory_migration.py b/tests/unit/extensions/test_adk/test_mssql_memory_migration.py index 38b404547..fe8d6cf5e 100644 --- a/tests/unit/extensions/test_adk/test_mssql_memory_migration.py +++ b/tests/unit/extensions/test_adk/test_mssql_memory_migration.py @@ -33,3 +33,11 @@ async def test_disabled_mssql_memory_migration_does_not_create_tables() -> None: async def test_other_adapters_are_unchanged_by_mssql_memory_upgrade() -> None: config = SqliteConfig(connection_config={"database": ":memory:"}, extension_config={"adk": {}}) assert await migration.up(MigrationContext(config=config)) == [] + + +@pytest.mark.anyio +async def test_memory_upgrade_requires_database_config() -> None: + from sqlspec.exceptions import SQLSpecError + + with pytest.raises(SQLSpecError, match="context must have a config"): + await migration.up() From 7fc244229a40fcf8a7eefa5abc1eb8ffe1d9fd3f Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 19:30:57 +0000 Subject: [PATCH 15/16] test(mssql): parameterize memory retention setup --- .../integration/adapters/mssql/test_memory_deduplication.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/adapters/mssql/test_memory_deduplication.py b/tests/integration/adapters/mssql/test_memory_deduplication.py index 5fe58597f..f81fb8c1b 100644 --- a/tests/integration/adapters/mssql/test_memory_deduplication.py +++ b/tests/integration/adapters/mssql/test_memory_deduplication.py @@ -1,13 +1,14 @@ """SQL Server key-range locking for concurrent ADK memory deduplication.""" from concurrent.futures import ThreadPoolExecutor -from datetime import datetime +from datetime import datetime, timedelta, timezone from threading import Barrier from uuid import uuid4 import pytest from pytest_databases.docker.mssql import MSSQLService +from sqlspec import sql from sqlspec.adapters.mssql_python import MssqlPythonConfig from sqlspec.adapters.mssql_python.adk import MssqlPythonADKMemoryStore from sqlspec.adapters.pymssql import PymssqlConfig @@ -126,7 +127,8 @@ def test_memory_search_scopes_limits_and_retention(mssql_service: MSSQLService) assert [entry["id"] for entry in store.search_entries("searchable", "app", "user", scope_filter="app")] == ["2"] assert store.search_entries("missing", "app", "user") == [] with config.provide_session() as driver: - driver.execute(f"UPDATE [{table}] SET inserted_at = DATEADD(day, -60, SYSUTCDATETIME())") + expired_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=60) + driver.execute(sql.update(table).set(inserted_at=expired_at)) driver.commit() assert store.delete_entries_older_than(30, app_name="app", scope="app") == 1 assert store.delete_entries_by_session("session-0") == 1 From 6afce6c19ab97efa04176e3ab4c7b6c3f23a172d Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 19:32:02 +0000 Subject: [PATCH 16/16] ci: allow cold setup before the integration watchdog --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7e6d9fd5..9b47f6b34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,7 +254,7 @@ jobs: fail-fast: true matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1