Skip to content

Commit ebdb0f6

Browse files
knethclaude
andauthored
Support SQL Alchemy 2.1 (#253)
* Support SQL Alchemy 2.1 * Add CLAUDE.md * Consolidate 2.1 support into `core20.py` by guarding the three SA 2.1-specific differences behind `SA_VERSION >= SA_2_1` checks: - `visiting_cte` parameter and `toplevel` logic in `visit_update` - `update_post_criteria_clause` (renamed from `update_limit_clause`) - removal of the `_ordered_values` branch in `_get_crud_params` --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bce2f80 commit ebdb0f6

11 files changed

Lines changed: 206 additions & 27 deletions

File tree

.github/workflows/tests.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
os: ['ubuntu-22.04']
2323
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
2424
cratedb-version: ['nightly']
25-
sqla-version: ['<1.4', '<1.5', '<2.1']
25+
sqla-version: ['<1.4', '<1.5', '<2.1', '<2.2']
2626
pip-allow-prerelease: ['false']
2727

2828
exclude:
@@ -34,6 +34,12 @@ jobs:
3434
- python-version: '3.14'
3535
sqla-version: '<1.4'
3636

37+
# SQLAlchemy 2.1 requires Python 3.9+.
38+
- python-version: '3.7'
39+
sqla-version: '<2.2'
40+
- python-version: '3.8'
41+
sqla-version: '<2.2'
42+
3743
# Another CI test matrix slot to test against prerelease versions of Python packages.
3844
include:
3945
- os: 'ubuntu-latest'

CLAUDE.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
`sqlalchemy-cratedb` is a SQLAlchemy dialect for CrateDB, a distributed SQL database. It supports SQLAlchemy 1.3 through 2.1 (with ongoing 2.1 compatibility work on the current branch).
8+
9+
## Development Setup
10+
11+
```bash
12+
source bootstrap.sh # Creates .venv with Python 3.11, installs all deps in editable mode
13+
```
14+
15+
Environment variables that influence bootstrap:
16+
- `CRATEDB_VERSION` (default: `5.5.1`) — CrateDB Docker image version
17+
- `SQLALCHEMY_VERSION` (default: `<2.2`) — SQLAlchemy version constraint
18+
- `PIP_ALLOW_PRERELEASE=true` — allow pre-release packages
19+
20+
## Common Commands
21+
22+
```bash
23+
poe format # Auto-format code (ruff + black)
24+
poe lint # Run linters (ruff, validate-pyproject)
25+
poe test # Run pytest + integration tests
26+
poe check # lint + test combined
27+
28+
# Run specific tests
29+
pytest tests/dict_test.py
30+
pytest -k SqlAlchemyCompilerTest
31+
pytest -k test_score
32+
33+
# Run integration/doctests
34+
python -m unittest -vvv tests/integration.py
35+
```
36+
37+
Tests require a live CrateDB instance via Docker (managed automatically by `cratedb_toolkit.testing.testcontainers`).
38+
39+
## Architecture
40+
41+
### Source layout (`src/sqlalchemy_cratedb/`)
42+
43+
- **`dialect.py`** — Core dialect: type mappings, Date/DateTime handling, schema reflection
44+
- **`compiler.py`** — SQL/DDL compilation: `CrateDDLCompiler`, `CrateTypeCompiler`, `CrateIdentifierPreparer`, and `rewrite_update()` for partial object updates
45+
- **`predicate.py`**`match()` predicate for full-text search
46+
- **`sa_version.py`** — Version detection; exports `SA_VERSION`, `SA_1_4`, `SA_2_0`, `SA_2_1` constants
47+
- **`compat/`** — Multi-version SQLAlchemy compatibility: `core10.py`, `core14.py`, `core20.py`, `core21.py`, `api13.py`
48+
- **`type/`** — Custom CrateDB types: `ObjectType` (JSON objects), `ObjectArray`, `FloatVector`, `Geopoint`, `Geoshape`
49+
- **`support/`** — Integrations and polyfills: `pandas.py` (bulk insert), `polyfill.py` (refresh-after-DML, uniqueness, autoincrement timestamps), `util.py`
50+
51+
### Key architectural patterns
52+
53+
**Multi-version compatibility:** The `compat/` directory contains separate modules for each major SQLAlchemy version. `sa_version.py` detects the installed version at runtime using `verlib2`, and code conditionally imports from the appropriate compat module. When adding features, check whether they need version-specific handling.
54+
55+
**Custom types:** CrateDB types (ObjectType, FloatVector, etc.) implement SQLAlchemy's bind/result processor pattern — `bind_processor()` converts Python → SQL, `result_processor()` converts SQL → Python. The `CrateTypeCompiler` generates the SQL type strings.
56+
57+
**Update rewriting:** `compiler.py::rewrite_update()` transforms partial dictionary updates on `ObjectType` columns into CrateDB's subscript assignment syntax (e.g., `obj['key'] = value`).
58+
59+
**Polyfills:** `support/polyfill.py` monkey-patches SQLAlchemy internals to add features CrateDB doesn't natively support (e.g., `refresh_after_dml`, `uniqueness_strategy`).
60+
61+
### Testing
62+
63+
Tests in `tests/` follow two patterns:
64+
- `*_test.py` files: unit/integration tests using pytest with a live CrateDB instance
65+
- `tests/integration.py`: doctests for documentation examples, run with `unittest`
66+
67+
The `conftest.py` provides a session-scoped `cratedb_service` fixture that starts CrateDB via Docker containers.
68+
69+
## Code Style
70+
71+
- Line length: 100 characters (ruff + black)
72+
- Ruff rules enforced: A, B, C4, E, ERA, F, I, PD, RET, S, T20, W, YTT
73+
- Mypy strict mode is configured but not always enforced in CI

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ dependencies = [
8787
"geojson>=2.5,<4",
8888
"importlib-metadata; python_version<'3.8'",
8989
"importlib-resources; python_version<'3.9'",
90-
"sqlalchemy>=1,<2.1",
90+
"sqlalchemy>=1,<2.2",
9191
"verlib2<0.4",
9292
]
9393
optional-dependencies.all = [

src/sqlalchemy_cratedb/compat/core20.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,31 @@
4646
from sqlalchemy.sql.dml import isinsert as _compile_state_isinsert
4747

4848
from sqlalchemy_cratedb.compiler import CrateCompiler
49+
from sqlalchemy_cratedb.sa_version import SA_2_1, SA_VERSION
4950

5051

5152
class CrateCompilerSA20(CrateCompiler):
52-
def visit_update(self, update_stmt, **kw):
53+
def visit_update(self, update_stmt, visiting_cte=None, **kw):
5354
compile_state = update_stmt._compile_state_factory(update_stmt, self, **kw)
5455
update_stmt = compile_state.statement
5556

56-
# [20] CrateDB patch.
57+
# CrateDB patch.
5758
if not compile_state._dict_parameters and not hasattr(update_stmt, "_crate_specific"):
58-
return super().visit_update(update_stmt, **kw)
59+
if SA_VERSION >= SA_2_1:
60+
return super().visit_update(update_stmt, visiting_cte=visiting_cte, **kw)
61+
else:
62+
return super().visit_update(update_stmt, **kw)
63+
64+
# SA 2.1 introduced visiting_cte for CTE support.
65+
if SA_VERSION >= SA_2_1:
66+
if visiting_cte is not None:
67+
kw["visiting_cte"] = visiting_cte
68+
toplevel = False
69+
else:
70+
toplevel = not self.stack
71+
else:
72+
toplevel = not self.stack
5973

60-
toplevel = not self.stack
6174
if toplevel:
6275
self.isupdate = True
6376
if not self.dml_compile_state:
@@ -152,7 +165,11 @@ def visit_update(self, update_stmt, **kw):
152165
if t:
153166
text += " WHERE " + t
154167

155-
limit_clause = self.update_limit_clause(update_stmt)
168+
# SA 2.1 renamed update_limit_clause to update_post_criteria_clause.
169+
if SA_VERSION >= SA_2_1:
170+
limit_clause = self.update_post_criteria_clause(update_stmt, **kw)
171+
else:
172+
limit_clause = self.update_limit_clause(update_stmt)
156173
if limit_clause:
157174
text += " " + limit_clause
158175

@@ -275,7 +292,8 @@ def _get_crud_params(
275292
assert mp is not None
276293
spd = mp[0]
277294
stmt_parameter_tuples = list(spd.items())
278-
elif compile_state._ordered_values:
295+
elif SA_VERSION < SA_2_1 and compile_state._ordered_values:
296+
# _ordered_values was removed in SA 2.1.
279297
spd = compile_state._dict_parameters
280298
stmt_parameter_tuples = compile_state._ordered_values
281299
elif compile_state._dict_parameters:

src/sqlalchemy_cratedb/compiler.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ def visit_TEXT(self, type_, **kw):
214214
def visit_DECIMAL(self, type_, **kw):
215215
return "DOUBLE"
216216

217+
def visit_double(self, type_, **kw):
218+
return "DOUBLE"
219+
217220
def visit_BIGINT(self, type_, **kw):
218221
return "LONG"
219222

src/sqlalchemy_cratedb/dialect.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,6 @@ def process(value):
163163
sqltypes.TIMESTAMP: DateTime,
164164
}
165165

166-
167166
if SA_VERSION >= SA_2_0:
168167
from .compat.core20 import CrateCompilerSA20
169168

src/sqlalchemy_cratedb/sa_version.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@
2626

2727
SA_1_4 = Version("1.4.0b1")
2828
SA_2_0 = Version("2.0.0")
29+
SA_2_1 = Version("2.1.0b1")

src/sqlalchemy_cratedb/support/polyfill.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,10 @@ def check_uniqueness(mapper, connection, target):
5656
stmt = stmt.filter(
5757
getattr(sa_entity, attribute_name) == getattr(target, attribute_name)
5858
)
59-
stmt = stmt.compile(bind=connection.engine)
6059
results = connection.execute(stmt)
6160
if results.rowcount > 0:
6261
raise IntegrityError(
63-
statement=stmt,
62+
statement=str(stmt),
6463
params=[],
6564
orig=Exception(
6665
f"DuplicateKeyException in table '{target.__tablename__}' "

tests/create_table_test.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
except ImportError:
2727
from sqlalchemy.ext.declarative import declarative_base
2828

29-
from unittest import TestCase
29+
from unittest import TestCase, skipIf
3030
from unittest.mock import MagicMock, patch
3131

3232
from crate.client.cursor import Cursor
3333

3434
from sqlalchemy_cratedb import Geopoint, ObjectArray, ObjectType
35+
from sqlalchemy_cratedb.sa_version import SA_2_0, SA_VERSION
3536

3637
fake_cursor = MagicMock(name="fake_cursor")
3738
FakeCursor = MagicMock(name="FakeCursor", spec=Cursor)
@@ -383,3 +384,12 @@ class DummyTable(self.Base):
383384
),
384385
(),
385386
)
387+
388+
@skipIf(SA_VERSION < SA_2_0, "sa.Double was introduced in SA 2.0")
389+
def test_visit_double(self):
390+
"""
391+
Verify ``CrateTypeCompiler.visit_double()`` compiles ``sa.Double``
392+
to the CrateDB ``DOUBLE`` type keyword.
393+
"""
394+
result = sa.Double().compile(dialect=self.engine.dialect)
395+
self.assertEqual(str(result), "DOUBLE")

tests/dict_test.py

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from __future__ import absolute_import
2323

24+
import re
2425
from unittest import TestCase, skipIf
2526
from unittest.mock import MagicMock, patch
2627

@@ -29,14 +30,17 @@
2930
from sqlalchemy.sql import select
3031

3132
try:
32-
from sqlalchemy.orm import declarative_base
33+
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
3334
except ImportError:
3435
from sqlalchemy.ext.declarative import declarative_base
3536

37+
Mapped = None
38+
mapped_column = None
39+
3640
from crate.client.cursor import Cursor
3741

3842
from sqlalchemy_cratedb import ObjectArray, ObjectType
39-
from sqlalchemy_cratedb.sa_version import SA_1_4, SA_VERSION
43+
from sqlalchemy_cratedb.sa_version import SA_1_4, SA_2_1, SA_VERSION
4044

4145
fake_cursor = MagicMock(name="fake_cursor")
4246
FakeCursor = MagicMock(name="FakeCursor", spec=Cursor)
@@ -96,21 +100,83 @@ def test_update_with_dict_column(self):
96100
self.assertSQL("UPDATE mytable SET data['x'] = ? WHERE mytable.name = ?", stmt)
97101

98102
def set_up_character_and_cursor(self, return_value=None):
99-
return_value = return_value or [("Trillian", {})]
100-
fake_cursor.fetchall.return_value = return_value
101-
fake_cursor.description = (
102-
("characters_name", None, None, None, None, None, None),
103-
("characters_data", None, None, None, None, None, None),
104-
)
103+
"""
104+
Set up a ``Character`` model and a fake cursor, compatible with all
105+
supported SQLAlchemy versions.
106+
107+
**SA 2.1+** may issue SELECTs for different subsets of columns
108+
depending on which attributes have been expired after a flush. A
109+
dynamic ``execute`` side-effect is installed on the fake cursor so that
110+
``cursor.description`` and ``cursor.fetchall`` are adjusted to return
111+
exactly the columns present in each SELECT statement. The model uses
112+
``mapped_column`` / ``Mapped`` annotations available since SA 2.0.
113+
114+
**SA < 2.1** always selects the same fixed set of columns after a
115+
flush. A static cursor mock with a two-column description
116+
(``name``, ``data``) is used, matching the behaviour of those
117+
versions. The model uses plain ``sa.Column`` declarations.
118+
"""
105119
fake_cursor.rowcount = 1
106120
Base = declarative_base()
107121

108-
class Character(Base):
109-
__tablename__ = "characters"
110-
name = sa.Column(sa.String, primary_key=True)
111-
age = sa.Column(sa.Integer)
112-
data = sa.Column(ObjectType)
113-
data_list = sa.Column(ObjectArray)
122+
if SA_VERSION >= SA_2_1:
123+
# SA 2.1 may fire SELECTs for different subsets of columns depending
124+
# on what is expired. Use a dynamic mock that adjusts description and
125+
# return value to match exactly the columns present in each SELECT.
126+
data_rows = return_value or [("Trillian", {})]
127+
col_order = [
128+
"characters_name",
129+
"characters_age",
130+
"characters_data",
131+
"characters_data_list",
132+
]
133+
full_rows = [(r[0], None, r[1], None) for r in data_rows]
134+
135+
def _col_in_sql(col, sql):
136+
# Match 'characters.<attr>' where attr does not bleed into another column name.
137+
attr = col.replace("characters_", "")
138+
return bool(re.search(rf"characters\.{attr}(?!\w)", sql))
139+
140+
def execute_side_effect(sql, *args, **kwargs):
141+
sql_str = str(sql)
142+
if "SELECT" in sql_str.upper():
143+
cols = [c for c in col_order if _col_in_sql(c, sql_str)]
144+
if cols:
145+
indices = [col_order.index(c) for c in cols]
146+
fake_cursor.description = tuple(
147+
(c, None, None, None, None, None, None) for c in cols
148+
)
149+
fake_cursor.fetchall.return_value = [
150+
tuple(row[i] for i in indices) for row in full_rows
151+
]
152+
153+
fake_cursor.execute.side_effect = execute_side_effect
154+
# Set defaults so non-SELECT calls (INSERT/UPDATE) don't need description.
155+
fake_cursor.fetchall.return_value = []
156+
fake_cursor.description = ()
157+
158+
class Character(Base):
159+
__tablename__ = "characters"
160+
name: Mapped[str] = mapped_column(primary_key=True)
161+
age = sa.Column(sa.Integer)
162+
data = sa.Column(ObjectType)
163+
data_list = sa.Column(ObjectArray)
164+
165+
else:
166+
# Older SA always selects a fixed set of columns; use a static mock.
167+
fake_cursor.execute.side_effect = None
168+
fake_cursor.fetchall.return_value = return_value or [("Trillian", {})]
169+
fake_cursor.description = (
170+
("characters_name", None, None, None, None, None, None),
171+
("characters_data", None, None, None, None, None, None),
172+
)
173+
174+
class Character(Base):
175+
__tablename__ = "characters"
176+
name = sa.Column(sa.String, primary_key=True)
177+
age = sa.Column(sa.Integer)
178+
data = sa.Column(ObjectType)
179+
data_list = sa.Column(ObjectArray)
114180

115181
session = Session(bind=self.engine)
116182
return session, Character
@@ -301,6 +367,9 @@ def test_partial_dict_update_with_setitem_delitem_setitem(self):
301367

302368
def set_up_character_and_cursor_data_list(self, return_value=None):
303369
return_value = return_value or [("Trillian", {})]
370+
# Clear any side_effect installed by set_up_character_and_cursor so the
371+
# static description below is used as-is for this 2-column model.
372+
fake_cursor.execute.side_effect = None
304373
fake_cursor.fetchall.return_value = return_value
305374
fake_cursor.description = (
306375
("characters_name", None, None, None, None, None, None),

0 commit comments

Comments
 (0)