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.
- {sqlalchemy_cubrid-1.3.0/sqlalchemy_cubrid.egg-info → sqlalchemy_cubrid-1.4.1}/PKG-INFO +14 -11
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/README.md +10 -7
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/pyproject.toml +4 -4
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/__init__.py +1 -1
- sqlalchemy_cubrid-1.4.1/sqlalchemy_cubrid/_compat.py +39 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/compiler.py +22 -13
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/dialect.py +4 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/pycubrid_dialect.py +14 -1
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1/sqlalchemy_cubrid.egg-info}/PKG-INFO +14 -11
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/SOURCES.txt +3 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/requires.txt +3 -3
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_compiler.py +40 -17
- sqlalchemy_cubrid-1.4.1/test/test_logging.py +95 -0
- sqlalchemy_cubrid-1.4.1/test/test_stress_pool.py +260 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/AUTHORS +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/LICENSE +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/setup.cfg +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/aio_pycubrid_dialect.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/alembic_impl.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/base.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/dml.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/py.typed +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/requirements.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/trace.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/types.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/dependency_links.txt +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/entry_points.txt +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/top_level.txt +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_aio_integration.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_aio_pycubrid_dialect.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_alembic.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_base.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_dialect_offline.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_dialects.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_integration.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_json.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_packaging.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_pycubrid_dialect.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_requirements.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_show_create_table.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_suite.py +0 -0
- {sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/test/test_trace.py +0 -0
- {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
|
+
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.
|
|
40
|
-
Requires-Dist: mypy==1.
|
|
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
|
[](https://pypi.org/project/sqlalchemy-cubrid)
|
|
59
59
|
[](https://www.python.org)
|
|
60
60
|
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml)
|
|
61
|
+
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml)
|
|
61
62
|
[](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid)
|
|
62
63
|
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/blob/main/LICENSE)
|
|
63
64
|
[](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
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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.
|
|
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
|
[](https://pypi.org/project/sqlalchemy-cubrid)
|
|
9
9
|
[](https://www.python.org)
|
|
10
10
|
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml)
|
|
11
|
+
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml)
|
|
11
12
|
[](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid)
|
|
12
13
|
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/blob/main/LICENSE)
|
|
13
14
|
[](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
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
47
|
-
"mypy==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]
|
|
@@ -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
|
-
|
|
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
|
|
89
|
-
text += " OF " + ", ".join(self.process(col, **kw) for col in
|
|
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
|
|
96
|
-
offset_clause = select
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
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.
|
|
40
|
-
Requires-Dist: mypy==1.
|
|
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
|
[](https://pypi.org/project/sqlalchemy-cubrid)
|
|
59
59
|
[](https://www.python.org)
|
|
60
60
|
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/ci.yml)
|
|
61
|
+
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/actions/workflows/integration-full.yml)
|
|
61
62
|
[](https://codecov.io/gh/cubrid-labs/sqlalchemy-cubrid)
|
|
62
63
|
[](https://github.com/cubrid-labs/sqlalchemy-cubrid/blob/main/LICENSE)
|
|
63
64
|
[](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
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
14
|
-
mypy==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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
1449
|
-
stmt
|
|
1450
|
-
stmt
|
|
1451
|
-
stmt
|
|
1452
|
-
stmt
|
|
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]
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid/aio_pycubrid_dialect.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{sqlalchemy_cubrid-1.3.0 → sqlalchemy_cubrid-1.4.1}/sqlalchemy_cubrid.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|