Skip to content

Commit 1e349f5

Browse files
authored
Merge pull request #33 from gianchub/engine-fix
Engine Disposal
2 parents 8334923 + 98677e4 commit 1e349f5

8 files changed

Lines changed: 535 additions & 456 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [1.1.1]
4+
5+
12/12/2025
6+
7+
- Add `dispose_engines` flag to `Comparer` constructor.
8+
- Add `dispose` method to `Comparer` class.
9+
- Add tests for engine disposal.
10+
- Update README.md with new information.
11+
312
## [1.1.0]
413

514
10/12/2025

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
.PHONY: ruff-fix ruff-check ruff-format ruff-format-check lint format test install-tox-uv test-sqlalchemy14
2-
.PHONY: ty install-reqs docker-test-db-run build publish-test publish bump-version
2+
.PHONY: ty install-reqs update-reqs docker-test-db-run build publish-test publish bump-version
33

44
# Misc
55

@@ -41,6 +41,9 @@ ty:
4141

4242
# requirements
4343

44+
update-reqs:
45+
uv lock --upgrade
46+
4447
install-reqs:
4548
uv pip install -U -e ."[dev,lint,test]"
4649

README.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ result.dump_result('comparison_result.json')
4242
result.dump_errors('comparison_errors.json')
4343
```
4444

45-
You can also create a comparer directly from database URIs:
45+
You can create a comparer directly from database URIs, using the `from_params` classmethod:
4646

4747
```python
4848
from sqlalchemydiff.comparer import Comparer
@@ -56,23 +56,36 @@ comparer = Comparer.from_params(
5656
result = comparer.compare()
5757
```
5858

59+
60+
> [!NOTE]
61+
> When using the `from_params` classmethod, the engines will be disposed after the comparison is complete, to avoi leaving pooled connections open.
62+
> If instead you supply your own engines, **manage their lifecycle as needed**.
63+
> You can still pass a flag, `dispose_engines=True`, to the constructor to dispose the engines after the comparison is complete.
64+
65+
66+
### Aliases
67+
5968
You can use meaningful aliases for the results:
6069

6170
```python
6271
result = comparer.compare(one_alias='production', two_alias='staging')
6372
```
6473

74+
## Inspectors
75+
6576
The built-in inspectors includes: **tables**, **columns**, **primary keys**, **foreign keys**, **indexes**, **unique constraints**, **check constraints**, and **enums**.
6677

67-
### To ignore specific inspectors:
78+
### Ignoring inspectors:
79+
80+
To ignore specific inspectors, you can pass a list of inspector keys to the `compare` method.
6881

69-
For example, ignore enums and check constraints inspectors
82+
For example, to ignore enums and check constraints inspectors:
7083

7184
```python
7285
result = comparer.compare(ignore_inspectors=['enums', 'check_constraints'])
7386
```
7487

75-
## Custom Inspectors
88+
### Custom Inspectors
7689

7790
You can create your own custom inspectors to compare specific aspects of your database schemas.
7891

@@ -140,12 +153,11 @@ class MyCustomInspector(BaseInspector, DiffMixin):
140153
return hasattr(inspector, 'get_something')
141154
```
142155

143-
### Important Notes
144-
145-
- The `key` attribute must be unique, non-empty and must not start or end with whitespace
146-
- Use the `DiffMixin` helper methods (`_listdiff`, `_dictdiff`, `_itemsdiff`) for consistent comparison logic
156+
> [!IMPORTANT]
157+
> - The `key` attribute must be unique, non-empty and must not start or end with whitespace
158+
> - Use the `DiffMixin` helper methods (`_listdiff`, `_dictdiff`, `_itemsdiff`) for consistent comparison logic
147159
148-
### Example: A Custom Sequences Inspector
160+
#### Example: A Custom Sequences Inspector
149161

150162
This is a working example of a inspector that compares sequences.
151163

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sqlalchemy-diff"
3-
version = "1.1.0"
3+
version = "1.1.1"
44
authors = [
55
{ name = "Fabrizio Romano", email = "gianchub@gmail.com" },
66
{ name = "Mark McArdle", email = "m.mc4rdle@gmail.com" },

src/sqlalchemydiff/comparer.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,23 @@ class Comparer:
8888
8989
Simply call the `compare` method to get the result.
9090
91+
You can pass a flag, `dispose_engines`, to the constructor to dispose the engines after
92+
the comparison is complete. If you use the `from_params` classmethod, the engines will
93+
automatically be disposed after the comparison is complete.
94+
9195
You can customise how certain aspects of the comparison are performed by setting your own
9296
classes for the `ignore_spec_factory` and `compare_result_class` attributes.
9397
"""
9498

9599
ignore_spec_factory_class = IgnoreSpecFactory
96100
compare_result_class = CompareResult
97101

98-
def __init__(self, db_one_engine: Engine, db_two_engine: Engine):
102+
def __init__(
103+
self, db_one_engine: Engine, db_two_engine: Engine, *, dispose_engines: bool = False
104+
):
99105
self.db_one_engine = db_one_engine
100106
self.db_two_engine = db_two_engine
107+
self.__dispose_engines = dispose_engines
101108

102109
@classmethod
103110
def from_params(
@@ -112,7 +119,7 @@ def from_params(
112119
db_one_engine = DBConnectionFactory.create_engine(db_one_uri, **db_one_params)
113120
db_two_engine = DBConnectionFactory.create_engine(db_two_uri, **db_two_params)
114121

115-
return cls(db_one_engine, db_two_engine)
122+
return cls(db_one_engine, db_two_engine, dispose_engines=True)
116123

117124
def compare(
118125
self,
@@ -125,18 +132,23 @@ def compare(
125132

126133
filtered_inspectors = self._filter_inspectors(set(ignore_inspectors or set()))
127134

128-
result = {}
129-
with self.db_one_engine.begin(), self.db_two_engine.begin():
130-
for key, inspector_class in filtered_inspectors:
131-
inspector = inspector_class(one_alias=one_alias, two_alias=two_alias)
135+
try:
136+
result = {}
137+
with self.db_one_engine.begin(), self.db_two_engine.begin():
138+
for key, inspector_class in filtered_inspectors:
139+
inspector = inspector_class(one_alias=one_alias, two_alias=two_alias)
132140

133-
db_one_info = self._get_db_info(ignore_specs, inspector, self.db_one_engine)
134-
db_two_info = self._get_db_info(ignore_specs, inspector, self.db_two_engine)
141+
db_one_info = self._get_db_info(ignore_specs, inspector, self.db_one_engine)
142+
db_two_info = self._get_db_info(ignore_specs, inspector, self.db_two_engine)
135143

136-
if db_one_info is not None and db_two_info is not None:
137-
result[key] = inspector.diff(db_one_info, db_two_info)
144+
if db_one_info is not None and db_two_info is not None:
145+
result[key] = inspector.diff(db_one_info, db_two_info)
138146

139-
return self.compare_result_class(result, one_alias=one_alias, two_alias=two_alias)
147+
return self.compare_result_class(result, one_alias=one_alias, two_alias=two_alias)
148+
149+
finally:
150+
if self.__dispose_engines:
151+
self._dispose_engines()
140152

141153
def _filter_inspectors(
142154
self, ignore_inspectors: set[str] | None
@@ -157,3 +169,8 @@ def _get_db_info(
157169
return inspector.inspect(engine, ignore_specs)
158170
except InspectorNotSupported as e:
159171
logger.warning({"engine": engine, "inspector": inspector.key, "error": e.message})
172+
173+
def _dispose_engines(self) -> None:
174+
"""Dispose engines to close any pooled connections."""
175+
self.db_one_engine.dispose()
176+
self.db_two_engine.dispose()

tests/base.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,19 @@ def db_uri_two(self, make_postgres_uri, db_name_two):
2727

2828
@pytest.fixture
2929
def db_engine_one(self, db_uri_one):
30-
return get_engine(db_uri_one)
30+
engine = get_engine(db_uri_one)
31+
try:
32+
yield engine
33+
finally:
34+
engine.dispose()
3135

3236
@pytest.fixture
3337
def db_engine_two(self, db_uri_two):
34-
return get_engine(db_uri_two)
38+
engine = get_engine(db_uri_two)
39+
try:
40+
yield engine
41+
finally:
42+
engine.dispose()
3543

3644
@pytest.fixture
3745
def setup_db_one(self, db_uri_one, db_engine_one):

tests/test_comparer.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
3-
from unittest.mock import patch
3+
from contextlib import nullcontext
4+
from unittest.mock import MagicMock, patch
45

56
import pytest
67

@@ -135,8 +136,8 @@ def test_compare_transaction(
135136
result = comparer.compare()
136137
assert result.result == compare_result
137138
assert result.errors == compare_errors
138-
mock_begin_one.assert_called_once_with()
139-
mock_begin_two.assert_called_once_with()
139+
mock_begin_one.assert_called_once()
140+
mock_begin_two.assert_called_once()
140141

141142
@pytest.mark.usefixtures("setup_db_one", "setup_db_two")
142143
def test_dump_result(
@@ -158,6 +159,39 @@ def test_dump_result(
158159
assert json.load(f) == compare_errors
159160

160161

162+
class TestComparerEngineDisposal(BaseTest):
163+
@pytest.fixture
164+
def engines(self):
165+
engine_one = MagicMock()
166+
engine_two = MagicMock()
167+
engine_one.begin.side_effect = lambda: nullcontext()
168+
engine_two.begin.side_effect = lambda: nullcontext()
169+
return engine_one, engine_two
170+
171+
def test_from_params_disposes_engines(self, monkeypatch, engines):
172+
engine_one, engine_two = engines
173+
174+
monkeypatch.setattr(
175+
"sqlalchemydiff.connection.DBConnectionFactory.create_engine",
176+
MagicMock(side_effect=[engine_one, engine_two]),
177+
)
178+
179+
comparer = Comparer.from_params("postgresql://db_one", "postgresql://db_two")
180+
comparer.compare()
181+
182+
engine_one.dispose.assert_called_once()
183+
engine_two.dispose.assert_called_once()
184+
185+
def test_does_not_dispose_passed_engines(self, engines):
186+
engine_one, engine_two = engines
187+
188+
comparer = Comparer(engine_one, engine_two)
189+
comparer.compare()
190+
191+
engine_one.dispose.assert_not_called()
192+
engine_two.dispose.assert_not_called()
193+
194+
161195
@pytest.mark.is_sqlalchemy_1_4
162196
class TestComparerV14(BaseTest):
163197
@pytest.mark.usefixtures("setup_db_one", "setup_db_two")
@@ -171,11 +205,19 @@ def test_compare(self, db_engine_one, db_engine_two, compare_result_v14, compare
171205
class TestComparerSqlite(BaseTest):
172206
@pytest.fixture
173207
def sqlite_db_engine_one(self):
174-
return get_engine("sqlite:///:memory:")
208+
engine = get_engine("sqlite:///:memory:")
209+
try:
210+
yield engine
211+
finally:
212+
engine.dispose()
175213

176214
@pytest.fixture
177215
def sqlite_db_engine_two(self):
178-
return get_engine("sqlite:///:memory:")
216+
engine = get_engine("sqlite:///:memory:")
217+
try:
218+
yield engine
219+
finally:
220+
engine.dispose()
179221

180222
@pytest.fixture()
181223
def setup_db_one(self, sqlite_db_engine_one):

0 commit comments

Comments
 (0)