sqlalchemy-cubrid 1.3.0__tar.gz → 1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {sqlalchemy_cubrid-1.3.0/sqlalchemy_cubrid.egg-info → sqlalchemy_cubrid-1.4.1}/PKG-INFO +14 -11
  2. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/README.md +10 -7
  3. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/pyproject.toml +4 -4
  4. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/__init__.py +1 -1
  5. sqlalchemy_cubrid-1.4.1/sqlalchemy_cubrid/_compat.py +39 -0
  6. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/compiler.py +22 -13
  7. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/dialect.py +4 -0
  8. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/pycubrid_dialect.py +14 -1
  9. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1/sqlalchemy_cubrid.egg-info}/PKG-INFO +14 -11
  10. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/SOURCES.txt +3 -0
  11. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/requires.txt +3 -3
  12. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_compiler.py +40 -17
  13. sqlalchemy_cubrid-1.4.1/test/test_logging.py +95 -0
  14. sqlalchemy_cubrid-1.4.1/test/test_stress_pool.py +260 -0
  15. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/AUTHORS +0 -0
  16. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/LICENSE +0 -0
  17. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/setup.cfg +0 -0
  18. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/aio_pycubrid_dialect.py +0 -0
  19. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/alembic_impl.py +0 -0
  20. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/base.py +0 -0
  21. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/dml.py +0 -0
  22. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/py.typed +0 -0
  23. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/requirements.py +0 -0
  24. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/trace.py +0 -0
  25. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/types.py +0 -0
  26. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/dependency_links.txt +0 -0
  27. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/entry_points.txt +0 -0
  28. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/top_level.txt +0 -0
  29. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_aio_integration.py +0 -0
  30. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_aio_pycubrid_dialect.py +0 -0
  31. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_alembic.py +0 -0
  32. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_base.py +0 -0
  33. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_dialect_offline.py +0 -0
  34. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_dialects.py +0 -0
  35. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_integration.py +0 -0
  36. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_json.py +0 -0
  37. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_packaging.py +0 -0
  38. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_pycubrid_dialect.py +0 -0
  39. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_requirements.py +0 -0
  40. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_show_create_table.py +0 -0
  41. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_suite.py +0 -0
  42. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_trace.py +0 -0
  43. {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cubrid
3
- Version: 1.3.0
3
+ Version: 1.4.1
4
4
  Summary: CUBRID dialect for SQLAlchemy
5
5
  Author-email: Yeongseon Choe <yeongseon.choe@gmail.com>, Gyeongjun Paik <paikend@gmail.com>
6
6
  License-Expression: MIT
@@ -36,8 +36,8 @@ Provides-Extra: dev
36
36
  Requires-Dist: pytest>=7.0; extra == "dev"
37
37
  Requires-Dist: pytest-cov; extra == "dev"
38
38
  Requires-Dist: pytest-asyncio; extra == "dev"
39
- Requires-Dist: ruff==0.15.8; extra == "dev"
40
- Requires-Dist: mypy==1.19.1; extra == "dev"
39
+ Requires-Dist: ruff==0.15.11; extra == "dev"
40
+ Requires-Dist: mypy==1.20.1; extra == "dev"
41
41
  Requires-Dist: bandit[toml]==1.9.4; extra == "dev"
42
42
  Requires-Dist: pre-commit; extra == "dev"
43
43
  Requires-Dist: tox; extra == "dev"
@@ -45,7 +45,7 @@ Requires-Dist: alembic>=1.7; extra == "dev"
45
45
  Provides-Extra: alembic
46
46
  Requires-Dist: alembic>=1.7; extra == "alembic"
47
47
  Provides-Extra: pycubrid
48
- Requires-Dist: pycubrid<2.0,>=1.0; extra == "pycubrid"
48
+ Requires-Dist: pycubrid<2.0,>=1.2.0; extra == "pycubrid"
49
49
  Dynamic: license-file
50
50
 
51
51
  # sqlalchemy-cubrid
@@ -58,6 +58,7 @@ Dynamic: license-file
58
58
  [![PyPI version](https://img.shields.io/pypi/v/sqlalchemy-cubrid)](https://pypi.org/project/sqlalchemy-cubrid)
59
59
  [![python version](https://img.shields.io/pypi/pyversions/sqlalchemy-cubrid)](https://www.python.org)
60
60
  [![ci workflow](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml/badge.svg)](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml)
61
+ [![integration-full workflow](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml/badge.svg)](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml)
61
62
  [![coverage](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid/branch/main/graph/badge.svg)](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid)
62
63
  [![license](https://img.shields.io/github/license/cubrid-labs/sqlalchemy-cubrid)](https://github.com/cubrid-labs/sqlalchemy-cubrid/blob/main/LICENSE)
63
64
  [![GitHub stars](https://img.shields.io/github/stars/cubrid-labs/sqlalchemy-cubrid)](https://github.com/cubrid-labs/sqlalchemy-cubrid)
@@ -72,12 +73,14 @@ Dynamic: license-file
72
73
 
73
74
  CUBRID is a high-performance open-source relational database, widely adopted in
74
75
  Korean public-sector and enterprise applications. Until now, there was no
75
- production-ready SQLAlchemy dialect that supports the modern 2.0–2.1 API.
76
+ actively maintained SQLAlchemy dialect that supports the modern 2.0–2.1 API.
76
77
 
77
78
  **sqlalchemy-cubrid** bridges that gap:
78
79
 
79
80
  - Full SQLAlchemy 2.0–2.1 dialect with **statement caching** and **PEP 561 typing**
80
- - **577 offline tests** with **99%+ code coverage** — no database required to run them
81
+ - **619 offline tests** with **~98.26% code coverage** — no database required to run them
82
+ - **Concurrency stress tests** — `QueuePool` sync threaded + asyncio.gather workloads validated against live CUBRID
83
+ - **SQLAlchemy 2.2-ready compat shim** — private API access wrapped in `_compat.py` (still pinned `<2.2` until full SA 2.2 validation)
81
84
  - Tested against **4 CUBRID versions** (10.2, 11.0, 11.2, 11.4) across **Python 3.10 -- 3.14**
82
85
  - CUBRID-specific DML constructs: `ON DUPLICATE KEY UPDATE`, `MERGE`, `REPLACE INTO`
83
86
  - Alembic migration support out of the box
@@ -190,7 +193,7 @@ async with AsyncSession(engine) as session:
190
193
  - Schema reflection -- tables, views, columns, PKs, FKs, indexes, unique constraints, comments
191
194
  - Alembic migrations via `CubridImpl` (auto-discovered entry point)
192
195
  - All 6 CUBRID isolation levels (dual-granularity: class-level + instance-level)
193
- - Async support (available since v1.1.0) — `create_async_engine("cubrid+aiopycubrid://...")` via pycubrid.aio
196
+ - Async support — `create_async_engine("cubrid+aiopycubrid://...")` via pycubrid.aio
194
197
 
195
198
  ## Known Limitations
196
199
 
@@ -199,7 +202,7 @@ async with AsyncSession(engine) as session:
199
202
  - **No multi-schema** — single schema per database
200
203
  - **DDL auto-commits** — migrations are not transactional (`transactional_ddl = False`)
201
204
  - **SQLAlchemy 2.0–2.1 only** — pinned to `<2.2` due to internal API dependencies ([details](docs/ARCHITECTURE.md))
202
- - **Async requires pycubrid 1.1.0** — the `cubrid+aiopycubrid://` driver needs the async-capable pycubrid
205
+ - **Async requires pycubrid >= 1.2.0,<2.0** — the `cubrid+aiopycubrid://` driver needs the async-capable pycubrid package line currently supported by this project
203
206
 
204
207
  ## Documentation
205
208
 
@@ -225,8 +228,8 @@ async with AsyncSession(engine) as session:
225
228
  | CUBRID | 10.2, 11.0, 11.2, 11.4 |
226
229
  | SQLAlchemy | 2.0–2.1 |
227
230
  | Alembic | >=1.7 |
228
- | pycubrid (sync) | >=1.2.0 |
229
- | pycubrid (async) | >=1.2.0 |
231
+ | pycubrid (sync) | >=1.2.0,<2.0 |
232
+ | pycubrid (async) | >=1.2.0,<2.0 |
230
233
 
231
234
  ## FAQ
232
235
 
@@ -268,7 +271,7 @@ stmt = insert(users).values(name="Alice").on_duplicate_key_update(name="Alice Up
268
271
 
269
272
  ### Does sqlalchemy-cubrid support async?
270
273
 
271
- Yes. Use `create_async_engine("cubrid+aiopycubrid://...")` with the pycubrid async driver. Requires `pycubrid>=1.1.0`. All Core and ORM features work with `AsyncSession`.
274
+ Yes. Use `create_async_engine("cubrid+aiopycubrid://...")` with the pycubrid async driver. Requires `pycubrid>=1.2.0,<2.0`. All Core and ORM features work with `AsyncSession`.
272
275
 
273
276
 
274
277
  ## Related Projects
@@ -8,6 +8,7 @@
8
8
  [![PyPI version](https://img.shields.io/pypi/v/sqlalchemy-cubrid)](https://pypi.org/project/sqlalchemy-cubrid)
9
9
  [![python version](https://img.shields.io/pypi/pyversions/sqlalchemy-cubrid)](https://www.python.org)
10
10
  [![ci workflow](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml/badge.svg)](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml)
11
+ [![integration-full workflow](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml/badge.svg)](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml)
11
12
  [![coverage](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid/branch/main/graph/badge.svg)](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid)
12
13
  [![license](https://img.shields.io/github/license/cubrid-labs/sqlalchemy-cubrid)](https://github.com/cubrid-labs/sqlalchemy-cubrid/blob/main/LICENSE)
13
14
  [![GitHub stars](https://img.shields.io/github/stars/cubrid-labs/sqlalchemy-cubrid)](https://github.com/cubrid-labs/sqlalchemy-cubrid)
@@ -22,12 +23,14 @@
22
23
 
23
24
  CUBRID is a high-performance open-source relational database, widely adopted in
24
25
  Korean public-sector and enterprise applications. Until now, there was no
25
- production-ready SQLAlchemy dialect that supports the modern 2.0–2.1 API.
26
+ actively maintained SQLAlchemy dialect that supports the modern 2.0–2.1 API.
26
27
 
27
28
  **sqlalchemy-cubrid** bridges that gap:
28
29
 
29
30
  - Full SQLAlchemy 2.0–2.1 dialect with **statement caching** and **PEP 561 typing**
30
- - **577 offline tests** with **99%+ code coverage** — no database required to run them
31
+ - **619 offline tests** with **~98.26% code coverage** — no database required to run them
32
+ - **Concurrency stress tests** — `QueuePool` sync threaded + asyncio.gather workloads validated against live CUBRID
33
+ - **SQLAlchemy 2.2-ready compat shim** — private API access wrapped in `_compat.py` (still pinned `<2.2` until full SA 2.2 validation)
31
34
  - Tested against **4 CUBRID versions** (10.2, 11.0, 11.2, 11.4) across **Python 3.10 -- 3.14**
32
35
  - CUBRID-specific DML constructs: `ON DUPLICATE KEY UPDATE`, `MERGE`, `REPLACE INTO`
33
36
  - Alembic migration support out of the box
@@ -140,7 +143,7 @@ async with AsyncSession(engine) as session:
140
143
  - Schema reflection -- tables, views, columns, PKs, FKs, indexes, unique constraints, comments
141
144
  - Alembic migrations via `CubridImpl` (auto-discovered entry point)
142
145
  - All 6 CUBRID isolation levels (dual-granularity: class-level + instance-level)
143
- - Async support (available since v1.1.0) — `create_async_engine("cubrid+aiopycubrid://...")` via pycubrid.aio
146
+ - Async support — `create_async_engine("cubrid+aiopycubrid://...")` via pycubrid.aio
144
147
 
145
148
  ## Known Limitations
146
149
 
@@ -149,7 +152,7 @@ async with AsyncSession(engine) as session:
149
152
  - **No multi-schema** — single schema per database
150
153
  - **DDL auto-commits** — migrations are not transactional (`transactional_ddl = False`)
151
154
  - **SQLAlchemy 2.0–2.1 only** — pinned to `<2.2` due to internal API dependencies ([details](docs/ARCHITECTURE.md))
152
- - **Async requires pycubrid 1.1.0** — the `cubrid+aiopycubrid://` driver needs the async-capable pycubrid
155
+ - **Async requires pycubrid >= 1.2.0,<2.0** — the `cubrid+aiopycubrid://` driver needs the async-capable pycubrid package line currently supported by this project
153
156
 
154
157
  ## Documentation
155
158
 
@@ -175,8 +178,8 @@ async with AsyncSession(engine) as session:
175
178
  | CUBRID | 10.2, 11.0, 11.2, 11.4 |
176
179
  | SQLAlchemy | 2.0–2.1 |
177
180
  | Alembic | >=1.7 |
178
- | pycubrid (sync) | >=1.2.0 |
179
- | pycubrid (async) | >=1.2.0 |
181
+ | pycubrid (sync) | >=1.2.0,<2.0 |
182
+ | pycubrid (async) | >=1.2.0,<2.0 |
180
183
 
181
184
  ## FAQ
182
185
 
@@ -218,7 +221,7 @@ stmt = insert(users).values(name="Alice").on_duplicate_key_update(name="Alice Up
218
221
 
219
222
  ### Does sqlalchemy-cubrid support async?
220
223
 
221
- Yes. Use `create_async_engine("cubrid+aiopycubrid://...")` with the pycubrid async driver. Requires `pycubrid>=1.1.0`. All Core and ORM features work with `AsyncSession`.
224
+ Yes. Use `create_async_engine("cubrid+aiopycubrid://...")` with the pycubrid async driver. Requires `pycubrid>=1.2.0,<2.0`. All Core and ORM features work with `AsyncSession`.
222
225
 
223
226
 
224
227
  ## Related Projects
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sqlalchemy-cubrid"
7
- version = "1.3.0"
7
+ version = "1.4.1"
8
8
  description = "CUBRID dialect for SQLAlchemy"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -43,8 +43,8 @@ dev = [
43
43
  "pytest>=7.0",
44
44
  "pytest-cov",
45
45
  "pytest-asyncio",
46
- "ruff==0.15.8",
47
- "mypy==1.19.1",
46
+ "ruff==0.15.11",
47
+ "mypy==1.20.1",
48
48
  "bandit[toml]==1.9.4",
49
49
  "pre-commit",
50
50
  "tox",
@@ -54,7 +54,7 @@ alembic = [
54
54
  "alembic>=1.7",
55
55
  ]
56
56
  pycubrid = [
57
- "pycubrid>=1.0,<2.0",
57
+ "pycubrid>=1.2.0,<2.0",
58
58
  ]
59
59
 
60
60
  [project.urls]
@@ -52,7 +52,7 @@ from sqlalchemy.sql.sqltypes import (
52
52
  TIMESTAMP,
53
53
  )
54
54
 
55
- __version__ = "1.3.0"
55
+ __version__ = "1.4.1"
56
56
 
57
57
  __all__ = (
58
58
  "insert",
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy.sql import elements, visitors
6
+
7
+
8
+ def is_literal_value(value: Any) -> bool:
9
+ """Check if value is a plain Python literal (not a SQL element or schema object)."""
10
+ if isinstance(value, visitors.Visitable):
11
+ return False
12
+ if hasattr(value, "__clause_element__"):
13
+ return False
14
+ return True
15
+
16
+
17
+ def bind_with_type(element: elements.BindParameter[Any], type_: Any) -> elements.BindParameter[Any]:
18
+ """Create a new BindParameter preserving all flags but with the given type."""
19
+ return elements.BindParameter(
20
+ element.key,
21
+ element.value,
22
+ type_=type_,
23
+ unique=element.unique,
24
+ expanding=element.expanding,
25
+ literal_execute=element.literal_execute,
26
+ isoutparam=element.isoutparam,
27
+ )
28
+
29
+
30
+ def get_for_update_arg(select: Any) -> Any | None:
31
+ return getattr(select, "_for_update_arg", None)
32
+
33
+
34
+ def get_limit_clause(select: Any) -> Any | None:
35
+ return getattr(select, "_limit_clause", None)
36
+
37
+
38
+ def get_offset_clause(select: Any) -> Any | None:
39
+ return getattr(select, "_offset_clause", None)
@@ -15,6 +15,14 @@ from sqlalchemy.exc import CompileError
15
15
  from sqlalchemy.sql import compiler, elements
16
16
  from sqlalchemy.sql import sqltypes
17
17
 
18
+ from sqlalchemy_cubrid._compat import (
19
+ bind_with_type,
20
+ get_for_update_arg,
21
+ get_limit_clause,
22
+ get_offset_clause,
23
+ is_literal_value,
24
+ )
25
+
18
26
 
19
27
  class CubridCompiler(compiler.SQLCompiler):
20
28
  """SQLCompiler subclass for CUBRID."""
@@ -82,29 +90,30 @@ class CubridCompiler(compiler.SQLCompiler):
82
90
 
83
91
  CUBRID does NOT support NOWAIT or SKIP LOCKED.
84
92
  """
85
- if select._for_update_arg is None:
93
+ for_update_arg = get_for_update_arg(select)
94
+ if for_update_arg is None:
86
95
  return ""
87
96
  text = " FOR UPDATE"
88
- if select._for_update_arg.of:
89
- text += " OF " + ", ".join(self.process(col, **kw) for col in select._for_update_arg.of)
97
+ if for_update_arg.of:
98
+ text += " OF " + ", ".join(self.process(col, **kw) for col in for_update_arg.of)
90
99
  return text
91
100
 
92
101
  def limit_clause(self, select: Any, **kw: Any) -> str:
93
102
  # https://www.cubrid.org/manual/en/11.0/sql/query/select.html#limit-clause
94
103
  # SA 2.0: _limit_clause / _offset_clause are ClauseElements, not raw ints.
95
- limit_clause = select._limit_clause
96
- offset_clause = select._offset_clause
104
+ limit_clause = get_limit_clause(select)
105
+ offset_clause = get_offset_clause(select)
97
106
  if limit_clause is None and offset_clause is None:
98
107
  return ""
99
- elif limit_clause is None and offset_clause is not None:
108
+ if limit_clause is None:
109
+ assert offset_clause is not None
100
110
  return " \n LIMIT %s, 1073741823" % (self.process(offset_clause, **kw),)
101
- elif offset_clause is not None:
111
+ if offset_clause is not None:
102
112
  return " \n LIMIT %s, %s" % (
103
113
  self.process(offset_clause, **kw),
104
114
  self.process(limit_clause, **kw),
105
115
  )
106
- else:
107
- return " \n LIMIT %s" % (self.process(limit_clause, **kw),)
116
+ return " \n LIMIT %s" % (self.process(limit_clause, **kw),)
108
117
 
109
118
  def update_limit_clause(self, update_stmt: Any) -> str | None: # pyright: ignore[reportIncompatibleMethodOverride]
110
119
  # https://www.cubrid.org/manual/en/11.0/sql/query/update.html
@@ -168,14 +177,14 @@ class CubridCompiler(compiler.SQLCompiler):
168
177
 
169
178
  for column in (col for col in cols if col.key in on_duplicate_update):
170
179
  val = on_duplicate_update[column.key]
171
- if coercions._is_literal(val):
180
+ if is_literal_value(val):
172
181
  val = elements.BindParameter(None, val, type_=column.type)
173
182
  value_text = self.process(val.self_group(), use_schema=False)
174
183
  else:
175
184
 
176
185
  def replace(element: Any, captured_column: Any = column, **kw: Any) -> Any | None:
177
186
  if isinstance(element, elements.BindParameter) and element.type._isnull:
178
- return element._with_binary_element_type(captured_column.type)
187
+ return bind_with_type(element, captured_column.type)
179
188
  elif (
180
189
  isinstance(element, elements.ColumnClause)
181
190
  and element.table is on_duplicate.inserted_alias
@@ -208,7 +217,7 @@ class CubridCompiler(compiler.SQLCompiler):
208
217
 
209
218
  def visit_merge(self, merge_stmt: Any, **kw: Any) -> str:
210
219
  from sqlalchemy import exc
211
- from sqlalchemy.sql import coercions, elements
220
+ from sqlalchemy.sql import elements
212
221
 
213
222
  target = merge_stmt._target
214
223
  source = merge_stmt._using_source
@@ -246,7 +255,7 @@ class CubridCompiler(compiler.SQLCompiler):
246
255
  return self.process(column_key, **kw)
247
256
 
248
257
  def _render_value(value: Any, target_column: Any | None) -> str:
249
- if coercions._is_literal(value):
258
+ if is_literal_value(value):
250
259
  value = elements.BindParameter(
251
260
  None,
252
261
  value,
@@ -273,6 +273,10 @@ class CubridDialect(default.DefaultDialect):
273
273
 
274
274
  def initialize(self, connection: Any) -> None:
275
275
  super().initialize(connection)
276
+ log.debug(
277
+ "CUBRID dialect initialized: server_version=%s",
278
+ self.server_version_info,
279
+ )
276
280
 
277
281
  # ----- Reflection methods -----
278
282
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import logging
12
13
  from typing import Any, Callable, cast
13
14
 
14
15
  from sqlalchemy.engine.interfaces import DBAPIConnection, DBAPIModule, ConnectArgsType
@@ -17,6 +18,8 @@ from sqlalchemy.engine.url import URL
17
18
  from sqlalchemy_cubrid.base import CubridExecutionContext
18
19
  from sqlalchemy_cubrid.dialect import CubridDialect
19
20
 
21
+ log = logging.getLogger(__name__)
22
+
20
23
 
21
24
  class PyCubridExecutionContext(CubridExecutionContext):
22
25
  """Execution context for pycubrid connections.
@@ -70,6 +73,7 @@ class PyCubridDialect(CubridDialect):
70
73
  import pycubrid as dbapi_module
71
74
  except ImportError as e:
72
75
  raise e
76
+ log.debug("Loaded pycubrid DBAPI (version %s)", getattr(dbapi_module, "__version__", "?"))
73
77
  return cast(DBAPIModule, dbapi_module) # pyright: ignore[reportInvalidCast]
74
78
 
75
79
  # Keep legacy dbapi() for SA 1.x compat
@@ -88,13 +92,21 @@ class PyCubridDialect(CubridDialect):
88
92
  raise ValueError("Unexpected database URL format")
89
93
 
90
94
  opts = url.translate_connect_args(username="user", database="database")
91
- return (), {
95
+ kwargs = {
92
96
  "host": opts.get("host", "localhost"),
93
97
  "port": opts.get("port", 33000),
94
98
  "database": opts.get("database", ""),
95
99
  "user": opts.get("user", "dba"),
96
100
  "password": opts.get("password", ""),
97
101
  }
102
+ log.debug(
103
+ "connect args: host=%s port=%s database=%s user=%s",
104
+ kwargs["host"],
105
+ kwargs["port"],
106
+ kwargs["database"],
107
+ kwargs["user"],
108
+ )
109
+ return (), kwargs
98
110
 
99
111
  def on_connect(self) -> Callable[[Any], None] | None:
100
112
  """Return a callable to set up a new pycubrid connection.
@@ -108,6 +120,7 @@ class PyCubridDialect(CubridDialect):
108
120
  conn.autocommit = False
109
121
  if isolation_level is not None:
110
122
  self.set_isolation_level(conn, isolation_level)
123
+ log.debug("on_connect: autocommit=False isolation_level=%s", isolation_level)
111
124
 
112
125
  return connect
113
126
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cubrid
3
- Version: 1.3.0
3
+ Version: 1.4.1
4
4
  Summary: CUBRID dialect for SQLAlchemy
5
5
  Author-email: Yeongseon Choe <yeongseon.choe@gmail.com>, Gyeongjun Paik <paikend@gmail.com>
6
6
  License-Expression: MIT
@@ -36,8 +36,8 @@ Provides-Extra: dev
36
36
  Requires-Dist: pytest>=7.0; extra == "dev"
37
37
  Requires-Dist: pytest-cov; extra == "dev"
38
38
  Requires-Dist: pytest-asyncio; extra == "dev"
39
- Requires-Dist: ruff==0.15.8; extra == "dev"
40
- Requires-Dist: mypy==1.19.1; extra == "dev"
39
+ Requires-Dist: ruff==0.15.11; extra == "dev"
40
+ Requires-Dist: mypy==1.20.1; extra == "dev"
41
41
  Requires-Dist: bandit[toml]==1.9.4; extra == "dev"
42
42
  Requires-Dist: pre-commit; extra == "dev"
43
43
  Requires-Dist: tox; extra == "dev"
@@ -45,7 +45,7 @@ Requires-Dist: alembic>=1.7; extra == "dev"
45
45
  Provides-Extra: alembic
46
46
  Requires-Dist: alembic>=1.7; extra == "alembic"
47
47
  Provides-Extra: pycubrid
48
- Requires-Dist: pycubrid<2.0,>=1.0; extra == "pycubrid"
48
+ Requires-Dist: pycubrid<2.0,>=1.2.0; extra == "pycubrid"
49
49
  Dynamic: license-file
50
50
 
51
51
  # sqlalchemy-cubrid
@@ -58,6 +58,7 @@ Dynamic: license-file
58
58
  [![PyPI version](https://img.shields.io/pypi/v/sqlalchemy-cubrid)](https://pypi.org/project/sqlalchemy-cubrid)
59
59
  [![python version](https://img.shields.io/pypi/pyversions/sqlalchemy-cubrid)](https://www.python.org)
60
60
  [![ci workflow](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml/badge.svg)](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml)
61
+ [![integration-full workflow](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml/badge.svg)](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml)
61
62
  [![coverage](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid/branch/main/graph/badge.svg)](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid)
62
63
  [![license](https://img.shields.io/github/license/cubrid-labs/sqlalchemy-cubrid)](https://github.com/cubrid-labs/sqlalchemy-cubrid/blob/main/LICENSE)
63
64
  [![GitHub stars](https://img.shields.io/github/stars/cubrid-labs/sqlalchemy-cubrid)](https://github.com/cubrid-labs/sqlalchemy-cubrid)
@@ -72,12 +73,14 @@ Dynamic: license-file
72
73
 
73
74
  CUBRID is a high-performance open-source relational database, widely adopted in
74
75
  Korean public-sector and enterprise applications. Until now, there was no
75
- production-ready SQLAlchemy dialect that supports the modern 2.0–2.1 API.
76
+ actively maintained SQLAlchemy dialect that supports the modern 2.0–2.1 API.
76
77
 
77
78
  **sqlalchemy-cubrid** bridges that gap:
78
79
 
79
80
  - Full SQLAlchemy 2.0–2.1 dialect with **statement caching** and **PEP 561 typing**
80
- - **577 offline tests** with **99%+ code coverage** — no database required to run them
81
+ - **619 offline tests** with **~98.26% code coverage** — no database required to run them
82
+ - **Concurrency stress tests** — `QueuePool` sync threaded + asyncio.gather workloads validated against live CUBRID
83
+ - **SQLAlchemy 2.2-ready compat shim** — private API access wrapped in `_compat.py` (still pinned `<2.2` until full SA 2.2 validation)
81
84
  - Tested against **4 CUBRID versions** (10.2, 11.0, 11.2, 11.4) across **Python 3.10 -- 3.14**
82
85
  - CUBRID-specific DML constructs: `ON DUPLICATE KEY UPDATE`, `MERGE`, `REPLACE INTO`
83
86
  - Alembic migration support out of the box
@@ -190,7 +193,7 @@ async with AsyncSession(engine) as session:
190
193
  - Schema reflection -- tables, views, columns, PKs, FKs, indexes, unique constraints, comments
191
194
  - Alembic migrations via `CubridImpl` (auto-discovered entry point)
192
195
  - All 6 CUBRID isolation levels (dual-granularity: class-level + instance-level)
193
- - Async support (available since v1.1.0) — `create_async_engine("cubrid+aiopycubrid://...")` via pycubrid.aio
196
+ - Async support — `create_async_engine("cubrid+aiopycubrid://...")` via pycubrid.aio
194
197
 
195
198
  ## Known Limitations
196
199
 
@@ -199,7 +202,7 @@ async with AsyncSession(engine) as session:
199
202
  - **No multi-schema** — single schema per database
200
203
  - **DDL auto-commits** — migrations are not transactional (`transactional_ddl = False`)
201
204
  - **SQLAlchemy 2.0–2.1 only** — pinned to `<2.2` due to internal API dependencies ([details](docs/ARCHITECTURE.md))
202
- - **Async requires pycubrid 1.1.0** — the `cubrid+aiopycubrid://` driver needs the async-capable pycubrid
205
+ - **Async requires pycubrid >= 1.2.0,<2.0** — the `cubrid+aiopycubrid://` driver needs the async-capable pycubrid package line currently supported by this project
203
206
 
204
207
  ## Documentation
205
208
 
@@ -225,8 +228,8 @@ async with AsyncSession(engine) as session:
225
228
  | CUBRID | 10.2, 11.0, 11.2, 11.4 |
226
229
  | SQLAlchemy | 2.0–2.1 |
227
230
  | Alembic | >=1.7 |
228
- | pycubrid (sync) | >=1.2.0 |
229
- | pycubrid (async) | >=1.2.0 |
231
+ | pycubrid (sync) | >=1.2.0,<2.0 |
232
+ | pycubrid (async) | >=1.2.0,<2.0 |
230
233
 
231
234
  ## FAQ
232
235
 
@@ -268,7 +271,7 @@ stmt = insert(users).values(name="Alice").on_duplicate_key_update(name="Alice Up
268
271
 
269
272
  ### Does sqlalchemy-cubrid support async?
270
273
 
271
- Yes. Use `create_async_engine("cubrid+aiopycubrid://...")` with the pycubrid async driver. Requires `pycubrid>=1.1.0`. All Core and ORM features work with `AsyncSession`.
274
+ Yes. Use `create_async_engine("cubrid+aiopycubrid://...")` with the pycubrid async driver. Requires `pycubrid>=1.2.0,<2.0`. All Core and ORM features work with `AsyncSession`.
272
275
 
273
276
 
274
277
  ## Related Projects
@@ -3,6 +3,7 @@ LICENSE
3
3
  README.md
4
4
  pyproject.toml
5
5
  sqlalchemy_cubrid/__init__.py
6
+ sqlalchemy_cubrid/_compat.py
6
7
  sqlalchemy_cubrid/aio_pycubrid_dialect.py
7
8
  sqlalchemy_cubrid/alembic_impl.py
8
9
  sqlalchemy_cubrid/base.py
@@ -29,10 +30,12 @@ test/test_dialect_offline.py
29
30
  test/test_dialects.py
30
31
  test/test_integration.py
31
32
  test/test_json.py
33
+ test/test_logging.py
32
34
  test/test_packaging.py
33
35
  test/test_pycubrid_dialect.py
34
36
  test/test_requirements.py
35
37
  test/test_show_create_table.py
38
+ test/test_stress_pool.py
36
39
  test/test_suite.py
37
40
  test/test_trace.py
38
41
  test/test_types.py
@@ -10,12 +10,12 @@ CUBRID-Python
10
10
  pytest>=7.0
11
11
  pytest-cov
12
12
  pytest-asyncio
13
- ruff==0.15.8
14
- mypy==1.19.1
13
+ ruff==0.15.11
14
+ mypy==1.20.1
15
15
  bandit[toml]==1.9.4
16
16
  pre-commit
17
17
  tox
18
18
  alembic>=1.7
19
19
 
20
20
  [pycubrid]
21
- pycubrid<2.0,>=1.0
21
+ pycubrid<2.0,>=1.2.0
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
 
10
10
  import pytest
11
11
  import sqlalchemy as sa
12
+ from typing import Any, cast
12
13
  from sqlalchemy import Column, Integer, MetaData, String, Table, select
13
14
  from sqlalchemy.exc import CompileError
14
15
 
16
+ from sqlalchemy_cubrid._compat import bind_with_type, is_literal_value
15
17
  from sqlalchemy_cubrid.dialect import CubridDialect
16
18
 
17
19
 
@@ -256,6 +258,24 @@ class TestLiteralValueCompilation:
256
258
  assert "\\\\" in sql
257
259
 
258
260
 
261
+ class TestCompatHelpers:
262
+ def test_is_literal_value(self):
263
+ assert is_literal_value(1) is True
264
+ assert is_literal_value("value") is True
265
+ assert is_literal_value(users.c.name) is False
266
+ assert is_literal_value(sa.literal("value")) is False
267
+
268
+ def test_bind_with_type(self):
269
+ element = sa.bindparam("name", "value")
270
+ typed_element = bind_with_type(element, users.c.name.type)
271
+
272
+ assert typed_element is not element
273
+ assert typed_element.key == element.key
274
+ assert typed_element.value == element.value
275
+ assert typed_element.unique == element.unique
276
+ assert isinstance(typed_element.type, users.c.name.type.__class__)
277
+
278
+
259
279
  class TestFunctionCompilation:
260
280
  """Test function compilation (SYSDATE, UTC_TIMESTAMP)."""
261
281
 
@@ -508,7 +528,7 @@ class TestTypeCompilation:
508
528
  """Test OBJECT type compilation via mock type."""
509
529
  from sqlalchemy.sql import sqltypes
510
530
 
511
- class MockObject(sqltypes.TypeEngine):
531
+ class MockObject(sqltypes.TypeEngine[Any]):
512
532
  __visit_name__ = "OBJECT"
513
533
 
514
534
  obj = MockObject()
@@ -527,7 +547,7 @@ class TestTypeCompilation:
527
547
  """Test MONETARY type compilation via mock type."""
528
548
  from sqlalchemy.sql import sqltypes
529
549
 
530
- class MockMonetary(sqltypes.TypeEngine):
550
+ class MockMonetary(sqltypes.TypeEngine[Any]):
531
551
  __visit_name__ = "MONETARY"
532
552
 
533
553
  mon = MockMonetary()
@@ -538,7 +558,7 @@ class TestTypeCompilation:
538
558
  """Test visit_datetime (lowercase) for datetime types."""
539
559
  from sqlalchemy.sql import sqltypes
540
560
 
541
- class MockDatetime(sqltypes.TypeDecorator):
561
+ class MockDatetime(sqltypes.TypeDecorator[Any]):
542
562
  impl = sqltypes.DateTime
543
563
  __visit_name__ = "datetime"
544
564
 
@@ -550,7 +570,7 @@ class TestTypeCompilation:
550
570
  """Test visit_DATETIME (uppercase) for DATETIME types."""
551
571
  from sqlalchemy.sql import sqltypes
552
572
 
553
- class MockDATETIME(sqltypes.TypeEngine):
573
+ class MockDATETIME(sqltypes.TypeEngine[Any]):
554
574
  __visit_name__ = "DATETIME"
555
575
 
556
576
  dt = MockDATETIME()
@@ -566,10 +586,11 @@ class TestTypeCompilation:
566
586
 
567
587
  t = VARCHAR(length=100)
568
588
  # _get(key, type_, kw) should return kw[key] or type_.key
569
- result = compiler._get("length", t, {})
589
+ get_method = getattr(compiler, "_get")
590
+ result = get_method("length", t, {})
570
591
  assert result == 100
571
592
  # Test with kw override
572
- result = compiler._get("length", t, {"length": 200})
593
+ result = get_method("length", t, {"length": 200})
573
594
  assert result == 200
574
595
 
575
596
 
@@ -1343,12 +1364,12 @@ class TestCoverageEdgeCases:
1343
1364
 
1344
1365
  def patched_process(element, **kw):
1345
1366
  if element is mock_cast.typeclause:
1346
- return None
1367
+ return cast(str, cast(object, None))
1347
1368
  if element is mock_self_group:
1348
1369
  return "users.name"
1349
1370
  return original_process(element, **kw)
1350
1371
 
1351
- compiler_obj.process = patched_process
1372
+ object.__setattr__(compiler_obj, "process", patched_process)
1352
1373
  result = compiler_obj.visit_cast(mock_cast)
1353
1374
  assert result == "users.name"
1354
1375
 
@@ -1397,7 +1418,8 @@ class TestCoverageEdgeCases:
1397
1418
  ) as mock_prop:
1398
1419
  mock_exec = MagicMock(spec=[])
1399
1420
  mock_prop.return_value = mock_exec
1400
- result = compiler_obj.visit_on_duplicate_key_update(on_dup)
1421
+ visit_on_duplicate_key_update = getattr(compiler_obj, "visit_on_duplicate_key_update")
1422
+ result = visit_on_duplicate_key_update(on_dup)
1401
1423
  assert result == "ON DUPLICATE KEY UPDATE"
1402
1424
 
1403
1425
  def test_on_duplicate_key_update_isnull_bind_param(self):
@@ -1445,11 +1467,11 @@ class TestCoverageEdgeCases:
1445
1467
  from sqlalchemy_cubrid.dml import Merge
1446
1468
 
1447
1469
  stmt = Merge.__new__(Merge)
1448
- stmt._target = None
1449
- stmt._using_source = users
1450
- stmt._on_condition = users.c.id == users.c.id
1451
- stmt._when_matched = {"values": {"name": "test"}}
1452
- stmt._when_not_matched = None
1470
+ object.__setattr__(stmt, "_target", None)
1471
+ object.__setattr__(stmt, "_using_source", users)
1472
+ object.__setattr__(stmt, "_on_condition", users.c.id == users.c.id)
1473
+ object.__setattr__(stmt, "_when_matched", {"values": {"name": "test"}})
1474
+ object.__setattr__(stmt, "_when_not_matched", None)
1453
1475
  with pytest.raises(Exception, match="requires a target table"):
1454
1476
  _compile(stmt)
1455
1477
 
@@ -1503,6 +1525,7 @@ class TestCoverageEdgeCases:
1503
1525
  stmt = stmt.when_matched_then_update({users.c.name: source.c.name})
1504
1526
  # Inject a processable element with no .name attr: sa.text()
1505
1527
  text_element = sa.text("custom_col")
1528
+ assert stmt._when_matched is not None
1506
1529
  stmt._when_matched["values"][text_element] = source.c.name
1507
1530
  sql = _compile(stmt)
1508
1531
  assert "WHEN MATCHED THEN UPDATE SET" in sql
@@ -1575,7 +1598,7 @@ class TestDmlCoverage:
1575
1598
  )
1576
1599
  stmt = insert(t).values(id=1, name="test")
1577
1600
  # table.c is a ReadOnlyColumnCollection, which is a ColumnCollection
1578
- stmt = stmt.on_duplicate_key_update(t.c)
1601
+ stmt = stmt.on_duplicate_key_update(dict(t.c))
1579
1602
  sql = _compile(stmt)
1580
1603
  assert "ON DUPLICATE KEY UPDATE" in sql
1581
1604
 
@@ -1615,7 +1638,7 @@ class TestDmlCoverage:
1615
1638
  )
1616
1639
  stmt = merge(target).using(source).on(target.c.id == source.c.id)
1617
1640
  # Pass ColumnCollection (table.c) as values_dict_or_column_list
1618
- stmt = stmt.when_not_matched_then_insert(source.c)
1641
+ stmt = stmt.when_not_matched_then_insert(list(source.c))
1619
1642
  sql = _compile(stmt)
1620
1643
  assert "WHEN NOT MATCHED THEN INSERT" in sql
1621
1644
 
@@ -1688,7 +1711,7 @@ class TestDialectReflectionExceptionPaths:
1688
1711
  result = dialect.get_columns(conn, "test_table", None)
1689
1712
  assert len(result) == 1
1690
1713
  assert result[0]["name"] == "id"
1691
- assert result[0]["comment"] is None
1714
+ assert result[0].get("comment") is None
1692
1715
 
1693
1716
  def test_get_pk_constraint_exception(self):
1694
1717
  """dialect.py lines 303-304: Exception in constraint query is caught."""
@@ -0,0 +1,95 @@
1
+ """Tests for logging output in sqlalchemy-cubrid dialect modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from sqlalchemy_cubrid.pycubrid_dialect import PyCubridDialect
11
+
12
+
13
+ class TestPyCubridDialectLogging:
14
+ """Verify PyCubridDialect emits expected log messages."""
15
+
16
+ def test_import_dbapi_logs_version(self, caplog: pytest.LogCaptureFixture) -> None:
17
+ """import_dbapi() should log the loaded pycubrid version."""
18
+ fake_module = MagicMock()
19
+ fake_module.__version__ = "1.2.0"
20
+
21
+ with patch.dict("sys.modules", {"pycubrid": fake_module}):
22
+ with caplog.at_level(logging.DEBUG, logger="sqlalchemy_cubrid.pycubrid_dialect"):
23
+ result = PyCubridDialect.import_dbapi()
24
+
25
+ assert any("pycubrid" in m and "1.2.0" in m for m in caplog.messages)
26
+ assert result is fake_module
27
+
28
+ def test_create_connect_args_logs(self, caplog: pytest.LogCaptureFixture) -> None:
29
+ """create_connect_args() should log connection target."""
30
+ dialect = PyCubridDialect()
31
+ url = MagicMock()
32
+ url.translate_connect_args.return_value = {
33
+ "host": "myhost",
34
+ "port": 33000,
35
+ "database": "mydb",
36
+ "user": "dba",
37
+ "password": "secret",
38
+ }
39
+
40
+ with caplog.at_level(logging.DEBUG, logger="sqlalchemy_cubrid.pycubrid_dialect"):
41
+ dialect.create_connect_args(url)
42
+
43
+ log_text = "\n".join(caplog.messages)
44
+ assert "myhost" in log_text
45
+ assert "33000" in log_text or "mydb" in log_text
46
+ assert "secret" not in log_text
47
+
48
+ def test_on_connect_logs_isolation_level(self, caplog: pytest.LogCaptureFixture) -> None:
49
+ """on_connect() should log isolation level setting."""
50
+ dialect = PyCubridDialect()
51
+ dialect._isolation_level = "SERIALIZABLE"
52
+
53
+ fn = dialect.on_connect()
54
+ assert fn is not None
55
+
56
+ mock_conn = MagicMock()
57
+ with caplog.at_level(logging.DEBUG, logger="sqlalchemy_cubrid.pycubrid_dialect"):
58
+ fn(mock_conn)
59
+
60
+ assert any("isolation_level" in m for m in caplog.messages)
61
+
62
+
63
+ class TestCubridDialectLogging:
64
+ """Verify CubridDialect emits expected log messages."""
65
+
66
+ def test_dialect_has_module_logger(self) -> None:
67
+ """CubridDialect module should have a logger configured."""
68
+ import sqlalchemy_cubrid.dialect as dialect_mod
69
+
70
+ assert hasattr(dialect_mod, "log")
71
+ assert dialect_mod.log.name == "sqlalchemy_cubrid.dialect"
72
+
73
+
74
+ class TestLoggingDoesNotLeakCredentials:
75
+ """Ensure credentials never appear in dialect log output."""
76
+
77
+ def test_create_connect_args_no_password_in_logs(
78
+ self, caplog: pytest.LogCaptureFixture
79
+ ) -> None:
80
+ """Password must never appear in log messages."""
81
+ dialect = PyCubridDialect()
82
+ url = MagicMock()
83
+ url.translate_connect_args.return_value = {
84
+ "host": "localhost",
85
+ "port": 33000,
86
+ "database": "testdb",
87
+ "user": "admin",
88
+ "password": "my_secret_pass_xyz",
89
+ }
90
+
91
+ with caplog.at_level(logging.DEBUG, logger="sqlalchemy_cubrid.pycubrid_dialect"):
92
+ dialect.create_connect_args(url)
93
+
94
+ full_log = "\n".join(caplog.messages)
95
+ assert "my_secret_pass_xyz" not in full_log
@@ -0,0 +1,260 @@
1
+ """Connection pool concurrency stress tests against a live CUBRID instance.
2
+
3
+ Exercises QueuePool concurrent checkouts (threading + asyncio), pool
4
+ overflow under burst load, and pool_recycle behavior.
5
+
6
+ Skipped automatically when no CUBRID instance is available.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import threading
14
+ import time
15
+ import uuid
16
+ from concurrent.futures import ThreadPoolExecutor
17
+
18
+ import pytest
19
+ from sqlalchemy import create_engine, text
20
+ from sqlalchemy.exc import TimeoutError as SATimeoutError
21
+ from sqlalchemy.ext.asyncio import create_async_engine
22
+ from sqlalchemy.pool import QueuePool
23
+
24
+
25
+ _DEFAULT_URL = "cubrid+pycubrid://dba@localhost:33000/testdb"
26
+ _DEFAULT_AURL = "cubrid+aiopycubrid://dba@localhost:33000/testdb"
27
+
28
+
29
+ def _sync_url() -> str:
30
+ return os.environ.get("CUBRID_TEST_URL", _DEFAULT_URL)
31
+
32
+
33
+ def _async_url() -> str:
34
+ return os.environ.get("CUBRID_TEST_AURL", _DEFAULT_AURL)
35
+
36
+
37
+ def _can_connect() -> bool:
38
+ try:
39
+ engine = create_engine(_sync_url())
40
+ with engine.connect() as conn:
41
+ conn.execute(text("SELECT 1"))
42
+ engine.dispose()
43
+ return True
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ pytestmark = pytest.mark.skipif(
49
+ not _can_connect(),
50
+ reason="CUBRID instance not available (set CUBRID_TEST_URL)",
51
+ )
52
+
53
+
54
+ def _table() -> str:
55
+ return f"sa_stress_{uuid.uuid4().hex[:8]}"
56
+
57
+
58
+ @pytest.fixture
59
+ def shared_table():
60
+ table = _table()
61
+ engine = create_engine(_sync_url())
62
+ with engine.begin() as conn:
63
+ conn.execute(
64
+ text(f"CREATE TABLE {table} (id INT AUTO_INCREMENT PRIMARY KEY, worker INT, n INT)")
65
+ )
66
+ engine.dispose()
67
+ yield table
68
+ cleanup = create_engine(_sync_url())
69
+ with cleanup.begin() as conn:
70
+ conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
71
+ cleanup.dispose()
72
+
73
+
74
+ class TestQueuePoolConcurrency:
75
+ def test_concurrent_checkouts_within_pool_size(self, shared_table: str) -> None:
76
+ engine = create_engine(
77
+ _sync_url(),
78
+ poolclass=QueuePool,
79
+ pool_size=8,
80
+ max_overflow=0,
81
+ pool_timeout=10,
82
+ )
83
+ try:
84
+ n_workers = 8
85
+ per_worker = 20
86
+ errors: list[BaseException] = []
87
+ lock = threading.Lock()
88
+
89
+ def worker(idx: int) -> int:
90
+ try:
91
+ with engine.begin() as conn:
92
+ for i in range(per_worker):
93
+ conn.execute(
94
+ text(f"INSERT INTO {shared_table} (worker, n) VALUES (:w, :n)"),
95
+ {"w": idx, "n": i},
96
+ )
97
+ with engine.connect() as conn:
98
+ return conn.execute(
99
+ text(f"SELECT COUNT(*) FROM {shared_table} WHERE worker = :w"),
100
+ {"w": idx},
101
+ ).scalar_one()
102
+ except BaseException as exc:
103
+ with lock:
104
+ errors.append(exc)
105
+ return -1
106
+
107
+ with ThreadPoolExecutor(max_workers=n_workers) as ex:
108
+ results = list(ex.map(worker, range(n_workers)))
109
+
110
+ assert not errors, f"worker errors: {errors!r}"
111
+ assert all(r == per_worker for r in results), results
112
+ finally:
113
+ engine.dispose()
114
+
115
+ def test_overflow_absorbs_burst_then_settles(self, shared_table: str) -> None:
116
+ engine = create_engine(
117
+ _sync_url(),
118
+ poolclass=QueuePool,
119
+ pool_size=4,
120
+ max_overflow=12,
121
+ pool_timeout=15,
122
+ )
123
+ try:
124
+ n_workers = 16
125
+ errors: list[BaseException] = []
126
+ lock = threading.Lock()
127
+ barrier = threading.Barrier(n_workers)
128
+
129
+ def worker(idx: int) -> int:
130
+ try:
131
+ barrier.wait(timeout=10)
132
+ with engine.begin() as conn:
133
+ conn.execute(
134
+ text(f"INSERT INTO {shared_table} (worker, n) VALUES (:w, 0)"),
135
+ {"w": idx},
136
+ )
137
+ return 1
138
+ except BaseException as exc:
139
+ with lock:
140
+ errors.append(exc)
141
+ return -1
142
+
143
+ with ThreadPoolExecutor(max_workers=n_workers) as ex:
144
+ results = list(ex.map(worker, range(n_workers)))
145
+
146
+ assert not errors, f"burst errors: {errors!r}"
147
+ assert sum(results) == n_workers
148
+
149
+ with engine.connect() as conn:
150
+ total = conn.execute(text(f"SELECT COUNT(*) FROM {shared_table}")).scalar_one()
151
+ assert total == n_workers
152
+
153
+ assert engine.pool.checkedout() == 0
154
+ finally:
155
+ engine.dispose()
156
+
157
+ def test_pool_timeout_raises_when_exhausted(self) -> None:
158
+ engine = create_engine(
159
+ _sync_url(),
160
+ poolclass=QueuePool,
161
+ pool_size=2,
162
+ max_overflow=0,
163
+ pool_timeout=1,
164
+ )
165
+ try:
166
+ held = [engine.connect() for _ in range(2)]
167
+ try:
168
+ with pytest.raises(SATimeoutError):
169
+ engine.connect()
170
+ finally:
171
+ for c in held:
172
+ c.close()
173
+ finally:
174
+ engine.dispose()
175
+
176
+ def test_pool_recycle_replaces_old_connection(self) -> None:
177
+ engine = create_engine(
178
+ _sync_url(),
179
+ poolclass=QueuePool,
180
+ pool_size=2,
181
+ max_overflow=0,
182
+ pool_recycle=1,
183
+ pool_pre_ping=False,
184
+ )
185
+ try:
186
+ with engine.connect() as conn:
187
+ first_raw = conn.connection.dbapi_connection
188
+ conn.execute(text("SELECT 1"))
189
+
190
+ time.sleep(1.5)
191
+
192
+ with engine.connect() as conn:
193
+ second_raw = conn.connection.dbapi_connection
194
+ conn.execute(text("SELECT 1"))
195
+
196
+ assert first_raw is not second_raw
197
+ finally:
198
+ engine.dispose()
199
+
200
+
201
+ class TestAsyncPoolConcurrency:
202
+ @pytest.mark.asyncio
203
+ async def test_async_gather_within_pool_size(self, shared_table: str) -> None:
204
+ engine = create_async_engine(
205
+ _async_url(),
206
+ pool_size=8,
207
+ max_overflow=0,
208
+ pool_timeout=10,
209
+ )
210
+ try:
211
+ n = 8
212
+ per_task = 15
213
+
214
+ async def worker(idx: int) -> int:
215
+ async with engine.begin() as conn:
216
+ for i in range(per_task):
217
+ await conn.execute(
218
+ text(f"INSERT INTO {shared_table} (worker, n) VALUES (:w, :n)"),
219
+ {"w": idx, "n": i},
220
+ )
221
+ async with engine.connect() as conn:
222
+ result = await conn.execute(
223
+ text(f"SELECT COUNT(*) FROM {shared_table} WHERE worker = :w"),
224
+ {"w": idx},
225
+ )
226
+ return result.scalar_one()
227
+
228
+ results = await asyncio.gather(*(worker(i) for i in range(n)))
229
+ assert all(r == per_task for r in results), results
230
+ finally:
231
+ await engine.dispose()
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_async_overflow_burst(self, shared_table: str) -> None:
235
+ engine = create_async_engine(
236
+ _async_url(),
237
+ pool_size=4,
238
+ max_overflow=12,
239
+ pool_timeout=15,
240
+ )
241
+ try:
242
+ n = 16
243
+
244
+ async def worker(idx: int) -> int:
245
+ async with engine.begin() as conn:
246
+ await conn.execute(
247
+ text(f"INSERT INTO {shared_table} (worker, n) VALUES (:w, 0)"),
248
+ {"w": idx},
249
+ )
250
+ return 1
251
+
252
+ results = await asyncio.gather(*(worker(i) for i in range(n)))
253
+ assert sum(results) == n
254
+
255
+ async with engine.connect() as conn:
256
+ result = await conn.execute(text(f"SELECT COUNT(*) FROM {shared_table}"))
257
+ total = result.scalar_one()
258
+ assert total == n
259
+ finally:
260
+ await engine.dispose()