Skip to content

Commit 1250d94

Browse files
authored
feat: add mssql support (#34)
1 parent ae0cb75 commit 1250d94

10 files changed

Lines changed: 193 additions & 77 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,17 @@ jobs:
3535
fail-fast: true
3636
matrix:
3737
python-version: ["3.8", "3.9", "3.10", "3.11"]
38-
timeout-minutes: 15
38+
timeout-minutes: 30
3939
defaults:
4040
run:
4141
shell: bash
4242
steps:
4343
- name: Check out repository
4444
uses: actions/checkout@v4
4545

46+
- name: Install Microsoft ODBC Drivers
47+
run: sudo ACCEPT_EULA=Y apt-get install msodbcsql18 -y
48+
4649
- uses: pdm-project/setup-pdm@v3
4750
name: Set up PDM
4851
with:
@@ -65,6 +68,9 @@ jobs:
6568
- name: Check out repository
6669
uses: actions/checkout@v4
6770

71+
- name: Install Microsoft ODBC
72+
run: sudo ACCEPT_EULA=Y apt-get install msodbcsql18 -y
73+
6874
- name: Set up Python
6975
uses: actions/setup-python@v4
7076
with:

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ test-duckdb:
112112
test-spanner:
113113
$(ENV_PREFIX)pytest tests -m='integration and spanner'
114114

115+
.PHONY: test-mssql
116+
test-mssql:
117+
$(ENV_PREFIX)pytest tests -m='integration and mssql'
118+
115119
.PHONY: test-all-databases
116120
test-all-databases:
117121
$(ENV_PREFIX)pytest tests -m='integration and integration'

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ offering features such as:
3737
- SQLite via [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/) or [sqlite](https://docs.python.org/3/library/sqlite3.html)
3838
- Postgres via [asyncpg](https://magicstack.github.io/asyncpg/current/) or [psycopg3 (async or sync)](https://www.psycopg.org/psycopg3/)
3939
- MySQL via [asyncmy](https://github.com/long2ice/asyncmy)
40-
- Oracle via [oracledb](https://oracle.github.io/python-oracledb/)
40+
- Oracle via [oracledb](https://oracle.github.io/python-oracledb/) (tested on 18c and 23c)
4141
- Google Spanner via [spanner-sqlalchemy](https://github.com/googleapis/python-spanner-sqlalchemy/)
4242
- DuckDB via [duckdb_engine](https://github.com/Mause/duckdb_engine>)
43+
- Microsoft SQL Server via [pyodbc](https://github.com/mkleehammer/pyodbc>)
4344

4445
## Usage
4546

pdm.lock

Lines changed: 75 additions & 42 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ license = {text = "MIT"}
4141
name = "advanced_alchemy"
4242
readme = "README.md"
4343
requires-python = ">=3.8"
44-
version = "0.1.1"
44+
version = "0.2.0"
4545

4646
[project.urls]
4747
Changelog = "https://docs.advanced-alchemy.jolt.rs/latest/changelog"
@@ -71,6 +71,7 @@ dev = [
7171
"fastapi>=0.103.1",
7272
"msgspec>=0.18.2",
7373
"orjson>=3.9.7",
74+
"pyodbc>=4.0.39",
7475
]
7576
docs = [
7677
"sphinx>=7.1.2",
@@ -107,6 +108,32 @@ test = [
107108
"httpx>=0.24.1",
108109
]
109110

111+
[tool.pytest.ini_options]
112+
addopts = "-m='not integration' --dist=loadgroup"
113+
asyncio_mode = "auto"
114+
115+
markers = [
116+
"integration: SQLAlchemy integration tests",
117+
"asyncmy: SQLAlchemy MySQL (asyncmy) Tests",
118+
"asyncpg: SQLAlchemy Postgres (asyncpg) Tests",
119+
"psycopg_async: SQLAlchemy Postgres (psycopg async) Tests",
120+
"psycopg_sync: SQLAlchemy Postgres (psycopg sync) Tests",
121+
"aiosqlite: SQLAlchemy SQLite (aiosqlite) Tests",
122+
"sqlite: SQLAlchemy SQLite (sqlite) Tests",
123+
"oracledb: SQLAlchemy Oracle (oracledb) Tests",
124+
"spanner: SQLAlchemy Google Cloud Spanner (sqlalchemy-spanner) Tests",
125+
"duckdb: SQLAlchemy DuckDB (duckdb-engine) Tests",
126+
"mssql: SQLAlchemy Microsoft SQL Server (pyodbc) Tests",
127+
]
128+
129+
filterwarnings = [
130+
"ignore::DeprecationWarning:pkg_resources.*",
131+
"ignore::DeprecationWarning:google.rpc",
132+
"ignore::DeprecationWarning:google.gcloud",
133+
"ignore::DeprecationWarning:google.iam",
134+
"ignore::DeprecationWarning:google",
135+
]
136+
110137
[tool.black]
111138
line-length = 120
112139

@@ -229,31 +256,6 @@ ruff_fix = true
229256
SQLAlchemyAsyncRepository = "SQLAlchemySyncRepository"
230257
"sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session"
231258

232-
[tool.pytest.ini_options]
233-
addopts = "-m='not integration' --dist=loadgroup"
234-
asyncio_mode = "auto"
235-
236-
markers = [
237-
"integration: SQLAlchemy integration tests",
238-
"asyncmy: SQLAlchemy MySQL (asyncmy) Tests",
239-
"asyncpg: SQLAlchemy Postgres (asyncpg) Tests",
240-
"psycopg_async: SQLAlchemy Postgres (psycopg async) Tests",
241-
"psycopg_sync: SQLAlchemy Postgres (psycopg sync) Tests",
242-
"aiosqlite: SQLAlchemy SQLite (aiosqlite) Tests",
243-
"sqlite: SQLAlchemy SQLite (sqlite) Tests",
244-
"oracledb: SQLAlchemy Oracle (oracledb) Tests",
245-
"spanner: SQLAlchemy Google Cloud Spanner (sqlalchemy-spanner) Tests",
246-
"duckdb: SQLAlchemy Duckdb (duckdb-engine) Tests",
247-
]
248-
249-
filterwarnings = [
250-
"ignore::DeprecationWarning:pkg_resources.*",
251-
"ignore::DeprecationWarning:google.rpc",
252-
"ignore::DeprecationWarning:google.gcloud",
253-
"ignore::DeprecationWarning:google.iam",
254-
"ignore::DeprecationWarning:google",
255-
]
256-
257259
[tool.codespell]
258260
ignore-words-list = "selectin"
259261
skip = 'pdm.lock, examples/us_state_lookup.json'

tests/docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,12 @@ services:
5959
DATABASE_NAME: test-database
6060
depends_on:
6161
- spanner
62+
mssql:
63+
image: mcr.microsoft.com/mssql/server:2022-latest
64+
ports:
65+
- "1344:1433" # use a non-standard port here
66+
environment:
67+
SA_PASSWORD: Super-secret1
68+
MSSQL_PID: Developer
69+
ACCEPT_EULA: Accepted
70+
MSSQL_TCP_PORT: 1433

tests/docker_service_fixtures.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import asyncmy
1414
import asyncpg
1515
import oracledb
16+
import pyodbc
1617
import pytest
1718
from google.auth.credentials import AnonymousCredentials
1819
from google.cloud import spanner
@@ -141,7 +142,7 @@ async def mysql_responsive(host: str) -> bool:
141142

142143
@pytest.fixture()
143144
async def mysql_service(docker_services: DockerServiceRegistry) -> None:
144-
await docker_services.start("mysql", check=mysql_responsive)
145+
await docker_services.start("mysql", timeout=45, pause=1, check=mysql_responsive)
145146

146147

147148
async def postgres_responsive(host: str) -> bool:
@@ -258,3 +259,26 @@ def spanner_responsive(host: str) -> bool:
258259
async def spanner_service(docker_services: DockerServiceRegistry) -> None:
259260
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:9010"
260261
await docker_services.start("spanner", timeout=60, check=spanner_responsive)
262+
263+
264+
async def mssql_responsive(host: str) -> bool:
265+
await asyncio.sleep(1)
266+
try:
267+
port = 1344
268+
user = "sa"
269+
database = "master"
270+
conn = pyodbc.connect(
271+
connstring=f"encrypt=no; TrustServerCertificate=yes; driver={{ODBC Driver 18 for SQL Server}}; server={host},{port}; database={database}; UID={user}; PWD=Super-secret1",
272+
timeout=2,
273+
)
274+
with conn.cursor() as cursor:
275+
cursor.execute("select 1 as is_available")
276+
resp = cursor.fetchone()
277+
return resp[0] == 1 # type: ignore
278+
except Exception:
279+
return False
280+
281+
282+
@pytest.fixture()
283+
async def mssql_service(docker_services: DockerServiceRegistry) -> None:
284+
await docker_services.start("mssql", timeout=60, pause=1, check=mssql_responsive)

tests/integration/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@ def psycopg_engine(docker_ip: str, postgres_service: None) -> Engine:
128128
)
129129

130130

131+
@pytest.fixture()
132+
def mssql_engine(docker_ip: str, mssql_service: None) -> Engine:
133+
"""MS SQL instance for end-to-end testing."""
134+
return create_engine(
135+
URL(
136+
drivername="mssql+pyodbc",
137+
username="sa",
138+
password="Super-secret1",
139+
host=docker_ip,
140+
port=1344,
141+
database="master",
142+
query={
143+
"driver": "ODBC Driver 18 for SQL Server",
144+
"encrypt": "no",
145+
"TrustServerCertificate": "yes",
146+
}, # type:ignore[arg-type]
147+
),
148+
poolclass=NullPool,
149+
)
150+
151+
131152
@pytest.fixture()
132153
def sqlite_engine(tmp_path: Path) -> Generator[Engine, None, None]:
133154
"""SQLite engine for end-to-end testing.
@@ -188,6 +209,14 @@ def spanner_engine(docker_ip: str, spanner_service: None, monkeypatch: MonkeyPat
188209
pytest.mark.xdist_group("postgres"),
189210
],
190211
),
212+
pytest.param(
213+
"mssql_engine",
214+
marks=[
215+
pytest.mark.mssql,
216+
pytest.mark.integration,
217+
pytest.mark.xdist_group("mssql"),
218+
],
219+
),
191220
pytest.param(
192221
"sqlite_engine",
193222
marks=[

tests/integration/test_alembic_commands.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,25 @@
2424

2525

2626
@pytest.fixture()
27-
async def sync_sqlalchemy_config(engine: Engine, session_maker: sessionmaker[Session]) -> SQLAlchemySyncConfig:
27+
def sync_sqlalchemy_config(engine: Engine, session_maker: sessionmaker[Session]) -> SQLAlchemySyncConfig:
2828
return SQLAlchemySyncConfig(engine_instance=engine, session_maker=session_maker)
2929

3030

3131
@pytest.fixture()
32-
async def async_sqlalchemy_config(
32+
def async_sqlalchemy_config(
3333
async_engine: AsyncEngine,
3434
async_session_maker: async_sessionmaker[AsyncSession],
3535
) -> SQLAlchemyAsyncConfig:
3636
return SQLAlchemyAsyncConfig(engine_instance=async_engine, session_maker=async_session_maker)
3737

3838

3939
@pytest.fixture(params=[lazy_fixture("sync_sqlalchemy_config"), lazy_fixture("async_sqlalchemy_config")])
40-
async def alembic_commands(request: FixtureRequest) -> commands.AlembicCommands:
40+
def alembic_commands(request: FixtureRequest) -> commands.AlembicCommands:
4141
return commands.AlembicCommands(sqlalchemy_config=request.param)
4242

4343

4444
@pytest.fixture
45-
async def tmp_project_dir(monkeypatch: MonkeyPatch, tmp_path: Path) -> Path:
45+
def tmp_project_dir(monkeypatch: MonkeyPatch, tmp_path: Path) -> Path:
4646
path = tmp_path / "project_dir"
4747
path.mkdir(exist_ok=True)
4848
monkeypatch.chdir(path)

tests/integration/test_repository.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,14 @@ def first_author_id(raw_authors: RawRecordData) -> Any:
321321
pytest.mark.xdist_group("spanner"),
322322
],
323323
),
324+
pytest.param(
325+
"mssql_engine",
326+
marks=[
327+
pytest.mark.mssql,
328+
pytest.mark.integration,
329+
pytest.mark.xdist_group("mssql"),
330+
],
331+
),
324332
],
325333
)
326334
def engine(request: FixtureRequest, repository_pk_type: RepositoryPKType) -> Engine:
@@ -926,7 +934,7 @@ async def test_repo_filter_search(author_repo: AuthorRepository) -> None:
926934
existing_obj = await maybe_async(author_repo.list(SearchFilter(field_name="name", value="GATH", ignore_case=False)))
927935
# sqlite & mysql are case insensitive by default with a `LIKE`
928936
dialect = author_repo.session.bind.dialect.name if author_repo.session.bind else "default"
929-
expected_objs = 1 if dialect in {"sqlite", "mysql"} else 0
937+
expected_objs = 1 if dialect in {"sqlite", "mysql", "mssql"} else 0
930938
assert len(existing_obj) == expected_objs
931939
existing_obj = await maybe_async(author_repo.list(SearchFilter(field_name="name", value="GATH", ignore_case=True)))
932940
assert existing_obj[0].name == "Agatha Christie"
@@ -942,7 +950,7 @@ async def test_repo_filter_not_in_search(author_repo: AuthorRepository) -> None:
942950
)
943951
# sqlite & mysql are case insensitive by default with a `LIKE`
944952
dialect = author_repo.session.bind.dialect.name if author_repo.session.bind else "default"
945-
expected_objs = 1 if dialect in {"sqlite", "mysql"} else 2
953+
expected_objs = 1 if dialect in {"sqlite", "mysql", "mssql"} else 2
946954
assert len(existing_obj) == expected_objs
947955
existing_obj = await maybe_async(
948956
author_repo.list(NotInSearchFilter(field_name="name", value="GATH", ignore_case=True)),

0 commit comments

Comments
 (0)