sqlalchemy-cubrid 0.7.1__py3-none-any.whl
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/__init__.py +85 -0
- sqlalchemy_cubrid/alembic_impl.py +145 -0
- sqlalchemy_cubrid/base.py +428 -0
- sqlalchemy_cubrid/compiler.py +515 -0
- sqlalchemy_cubrid/dialect.py +700 -0
- sqlalchemy_cubrid/dml.py +318 -0
- sqlalchemy_cubrid/py.typed +0 -0
- sqlalchemy_cubrid/pycubrid_dialect.py +124 -0
- sqlalchemy_cubrid/requirements.py +270 -0
- sqlalchemy_cubrid/trace.py +92 -0
- sqlalchemy_cubrid/types.py +349 -0
- sqlalchemy_cubrid-0.7.1.dist-info/METADATA +281 -0
- sqlalchemy_cubrid-0.7.1.dist-info/RECORD +18 -0
- sqlalchemy_cubrid-0.7.1.dist-info/WHEEL +5 -0
- sqlalchemy_cubrid-0.7.1.dist-info/entry_points.txt +7 -0
- sqlalchemy_cubrid-0.7.1.dist-info/licenses/AUTHORS +2 -0
- sqlalchemy_cubrid-0.7.1.dist-info/licenses/LICENSE +21 -0
- sqlalchemy_cubrid-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
# sqlalchemy_cubrid/dialect.py
|
|
2
|
+
# Copyright (C) 2021-2026 by sqlalchemy-cubrid authors and contributors
|
|
3
|
+
# <see AUTHORS file>
|
|
4
|
+
#
|
|
5
|
+
# This module is part of sqlalchemy-cubrid and is released under
|
|
6
|
+
# the MIT License: http://www.opensource.org/licenses/mit-license.php
|
|
7
|
+
|
|
8
|
+
"""CUBRID dialect for SQLAlchemy 2.0."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import types as sqltypes
|
|
17
|
+
from sqlalchemy.engine import default, reflection
|
|
18
|
+
from sqlalchemy.sql import text
|
|
19
|
+
|
|
20
|
+
from sqlalchemy_cubrid.base import CubridExecutionContext, CubridIdentifierPreparer
|
|
21
|
+
from sqlalchemy_cubrid.compiler import (
|
|
22
|
+
CubridCompiler,
|
|
23
|
+
CubridDDLCompiler,
|
|
24
|
+
CubridTypeCompiler,
|
|
25
|
+
)
|
|
26
|
+
from sqlalchemy_cubrid.types import (
|
|
27
|
+
BIGINT,
|
|
28
|
+
BIT,
|
|
29
|
+
BLOB,
|
|
30
|
+
CHAR,
|
|
31
|
+
CLOB,
|
|
32
|
+
DECIMAL,
|
|
33
|
+
DOUBLE,
|
|
34
|
+
DOUBLE_PRECISION,
|
|
35
|
+
FLOAT,
|
|
36
|
+
MULTISET,
|
|
37
|
+
NCHAR,
|
|
38
|
+
NUMERIC,
|
|
39
|
+
NVARCHAR,
|
|
40
|
+
SEQUENCE,
|
|
41
|
+
SET,
|
|
42
|
+
SMALLINT,
|
|
43
|
+
STRING,
|
|
44
|
+
VARCHAR,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from sqlalchemy.types import (
|
|
48
|
+
DATE,
|
|
49
|
+
DATETIME,
|
|
50
|
+
INTEGER,
|
|
51
|
+
TIME,
|
|
52
|
+
TIMESTAMP,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
log = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Pre-compiled patterns for column type parsing in get_columns().
|
|
58
|
+
# Avoids re-compilation on every reflection call.
|
|
59
|
+
_RE_TYPE_PARAMS = re.compile(r"\([\d,]+\)")
|
|
60
|
+
_RE_LENGTH = re.compile(r"\((\d+)\)")
|
|
61
|
+
_RE_PRECISION_SCALE = re.compile(r"\((\d+)(?:,\s*(\d+))?\)")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# -----------------------------------------------------------------------
|
|
65
|
+
# Column-spec and ischema_names mappings
|
|
66
|
+
# -----------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
colspecs = {
|
|
69
|
+
sqltypes.Numeric: NUMERIC,
|
|
70
|
+
sqltypes.Float: FLOAT,
|
|
71
|
+
sqltypes.Time: TIME,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ischema_names maps CUBRID type names from SHOW COLUMNS to SA types.
|
|
75
|
+
# https://www.cubrid.org/manual/en/11.0/sql/datatype.html
|
|
76
|
+
ischema_names = {
|
|
77
|
+
# Numeric
|
|
78
|
+
"SHORT": SMALLINT,
|
|
79
|
+
"SMALLINT": SMALLINT,
|
|
80
|
+
"INTEGER": INTEGER,
|
|
81
|
+
"BIGINT": BIGINT,
|
|
82
|
+
"NUMERIC": NUMERIC,
|
|
83
|
+
"DECIMAL": DECIMAL,
|
|
84
|
+
"FLOAT": FLOAT,
|
|
85
|
+
"DOUBLE": DOUBLE,
|
|
86
|
+
"DOUBLE PRECISION": DOUBLE_PRECISION,
|
|
87
|
+
# Date/Time
|
|
88
|
+
"DATE": DATE,
|
|
89
|
+
"TIME": TIME,
|
|
90
|
+
"TIMESTAMP": TIMESTAMP,
|
|
91
|
+
"DATETIME": DATETIME,
|
|
92
|
+
# Bit Strings
|
|
93
|
+
"BIT": BIT,
|
|
94
|
+
"BIT VARYING": BIT,
|
|
95
|
+
# Character Strings
|
|
96
|
+
"CHAR": CHAR,
|
|
97
|
+
"VARCHAR": VARCHAR,
|
|
98
|
+
"NCHAR": NCHAR,
|
|
99
|
+
"CHAR VARYING": NVARCHAR,
|
|
100
|
+
"STRING": STRING,
|
|
101
|
+
# LOB
|
|
102
|
+
"BLOB": BLOB,
|
|
103
|
+
"CLOB": CLOB,
|
|
104
|
+
# Collection
|
|
105
|
+
"SET": SET,
|
|
106
|
+
"MULTISET": MULTISET,
|
|
107
|
+
"SEQUENCE": SEQUENCE,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -----------------------------------------------------------------------
|
|
112
|
+
# Dialect
|
|
113
|
+
# -----------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CubridDialect(default.DefaultDialect):
|
|
117
|
+
"""SQLAlchemy dialect for CUBRID."""
|
|
118
|
+
|
|
119
|
+
name = "cubrid"
|
|
120
|
+
driver = "cubrid"
|
|
121
|
+
|
|
122
|
+
# SA 2.0 statement caching
|
|
123
|
+
supports_statement_cache = True
|
|
124
|
+
|
|
125
|
+
# Compiler classes
|
|
126
|
+
statement_compiler = CubridCompiler
|
|
127
|
+
ddl_compiler = CubridDDLCompiler
|
|
128
|
+
type_compiler = CubridTypeCompiler
|
|
129
|
+
preparer = CubridIdentifierPreparer
|
|
130
|
+
execution_ctx_cls = CubridExecutionContext
|
|
131
|
+
|
|
132
|
+
# DBAPI
|
|
133
|
+
# https://www.cubrid.org/manual/en/11.0/api/python.html
|
|
134
|
+
default_paramstyle = "qmark"
|
|
135
|
+
|
|
136
|
+
# Type mappings
|
|
137
|
+
colspecs = colspecs
|
|
138
|
+
ischema_names = ischema_names
|
|
139
|
+
|
|
140
|
+
# Identifiers
|
|
141
|
+
# https://www.cubrid.org/manual/en/11.0/sql/identifier.html
|
|
142
|
+
max_identifier_length = 254
|
|
143
|
+
max_index_name_length = 254
|
|
144
|
+
max_constraint_name_length = 254
|
|
145
|
+
|
|
146
|
+
requires_name_normalize = True
|
|
147
|
+
|
|
148
|
+
# Data type support
|
|
149
|
+
supports_native_enum = False
|
|
150
|
+
supports_native_boolean = False # CUBRID uses SMALLINT for booleans
|
|
151
|
+
supports_native_decimal = True
|
|
152
|
+
|
|
153
|
+
# Column options
|
|
154
|
+
supports_sequences = False
|
|
155
|
+
|
|
156
|
+
# DDL
|
|
157
|
+
supports_alter = True
|
|
158
|
+
supports_comments = True
|
|
159
|
+
inline_comments = True
|
|
160
|
+
|
|
161
|
+
# DML
|
|
162
|
+
supports_default_values = True
|
|
163
|
+
supports_default_metavalue = True
|
|
164
|
+
supports_empty_insert = True
|
|
165
|
+
supports_multivalues_insert = True
|
|
166
|
+
supports_is_distinct_from = False
|
|
167
|
+
|
|
168
|
+
# RETURNING
|
|
169
|
+
insert_returning = False
|
|
170
|
+
update_returning = False
|
|
171
|
+
delete_returning = False
|
|
172
|
+
|
|
173
|
+
postfetch_lastrowid = True
|
|
174
|
+
|
|
175
|
+
def __init__(self, isolation_level=None, **kwargs):
|
|
176
|
+
super().__init__(**kwargs)
|
|
177
|
+
self.isolation_level = isolation_level
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def import_dbapi(cls):
|
|
181
|
+
"""Import and return the CUBRID DBAPI module (SA 2.0 API)."""
|
|
182
|
+
try:
|
|
183
|
+
import CUBRIDdb as cubrid_dbapi
|
|
184
|
+
except ImportError as e:
|
|
185
|
+
raise e
|
|
186
|
+
return cubrid_dbapi
|
|
187
|
+
|
|
188
|
+
# Keep legacy dbapi() for SA 1.x compat if needed
|
|
189
|
+
@classmethod
|
|
190
|
+
def dbapi(cls):
|
|
191
|
+
return cls.import_dbapi()
|
|
192
|
+
|
|
193
|
+
def create_connect_args(self, url):
|
|
194
|
+
"""Build DB-API connection arguments for CUBRID.
|
|
195
|
+
|
|
196
|
+
CUBRID connection string format::
|
|
197
|
+
|
|
198
|
+
CUBRID:host:port:db_name:::
|
|
199
|
+
"""
|
|
200
|
+
if url is None:
|
|
201
|
+
raise ValueError("Unexpected database URL format")
|
|
202
|
+
|
|
203
|
+
opts = url.translate_connect_args(username="user", database="database")
|
|
204
|
+
host = opts.get("host", "localhost")
|
|
205
|
+
port = opts.get("port", 33000)
|
|
206
|
+
database = opts.get("database", "")
|
|
207
|
+
username = opts.get("user", "")
|
|
208
|
+
password = opts.get("password", "")
|
|
209
|
+
|
|
210
|
+
connect_url = f"CUBRID:{host}:{port}:{database}:::"
|
|
211
|
+
args = (connect_url, username, password)
|
|
212
|
+
return args, {}
|
|
213
|
+
|
|
214
|
+
def initialize(self, connection):
|
|
215
|
+
super().initialize(connection)
|
|
216
|
+
|
|
217
|
+
# ----- Reflection methods -----
|
|
218
|
+
|
|
219
|
+
@reflection.cache
|
|
220
|
+
def get_columns(self, connection, table_name, schema=None, **kw):
|
|
221
|
+
"""Return column information for *table_name*.
|
|
222
|
+
|
|
223
|
+
Uses ``SHOW COLUMNS IN <table>`` which is available since CUBRID 9.x.
|
|
224
|
+
"""
|
|
225
|
+
columns = []
|
|
226
|
+
quoted = self.identifier_preparer.quote_identifier(table_name)
|
|
227
|
+
result = connection.execute(text(f"SHOW COLUMNS IN {quoted}"))
|
|
228
|
+
for row in result:
|
|
229
|
+
colname = row[0]
|
|
230
|
+
coltype_raw = row[1]
|
|
231
|
+
nullable = row[2] == "YES"
|
|
232
|
+
default_val = row[4]
|
|
233
|
+
autoincrement = "auto_increment" in row[5] if row[5] else False
|
|
234
|
+
|
|
235
|
+
# Strip length/precision from type string for lookup
|
|
236
|
+
coltype_key = _RE_TYPE_PARAMS.sub("", coltype_raw).strip()
|
|
237
|
+
|
|
238
|
+
if coltype_key in ("CHAR", "VARCHAR", "NCHAR", "CHAR VARYING"):
|
|
239
|
+
length_match = _RE_LENGTH.search(coltype_raw)
|
|
240
|
+
length = int(length_match.group(1)) if length_match else None
|
|
241
|
+
coltype = self.ischema_names[coltype_key](length)
|
|
242
|
+
elif coltype_key in ("NUMERIC", "DECIMAL"):
|
|
243
|
+
params_match = _RE_PRECISION_SCALE.search(coltype_raw)
|
|
244
|
+
if params_match:
|
|
245
|
+
precision = int(params_match.group(1))
|
|
246
|
+
scale = int(params_match.group(2)) if params_match.group(2) else None
|
|
247
|
+
coltype = self.ischema_names[coltype_key](precision=precision, scale=scale)
|
|
248
|
+
else:
|
|
249
|
+
coltype = self.ischema_names[coltype_key]()
|
|
250
|
+
else:
|
|
251
|
+
try:
|
|
252
|
+
coltype_cls = self.ischema_names[coltype_key]
|
|
253
|
+
# Some ischema entries are classes, some are instances
|
|
254
|
+
coltype = coltype_cls() if callable(coltype_cls) else coltype_cls
|
|
255
|
+
except KeyError:
|
|
256
|
+
from sqlalchemy.sql import util
|
|
257
|
+
|
|
258
|
+
util.warn("Did not recognize type '%s' of column '%s'" % (coltype_raw, colname))
|
|
259
|
+
coltype = sqltypes.NULLTYPE
|
|
260
|
+
|
|
261
|
+
columns.append(
|
|
262
|
+
{
|
|
263
|
+
"name": colname,
|
|
264
|
+
"type": coltype,
|
|
265
|
+
"nullable": nullable,
|
|
266
|
+
"default": default_val,
|
|
267
|
+
"autoincrement": autoincrement,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
comment_result = connection.execute(
|
|
273
|
+
text(
|
|
274
|
+
"SELECT attr_name, comment FROM _db_attribute "
|
|
275
|
+
"WHERE class_name = :name ORDER BY def_order"
|
|
276
|
+
),
|
|
277
|
+
{"name": table_name},
|
|
278
|
+
)
|
|
279
|
+
comment_map = {row[0]: row[1] for row in comment_result}
|
|
280
|
+
except Exception:
|
|
281
|
+
comment_map = {}
|
|
282
|
+
|
|
283
|
+
for column in columns:
|
|
284
|
+
column["comment"] = comment_map.get(column["name"])
|
|
285
|
+
|
|
286
|
+
return columns
|
|
287
|
+
|
|
288
|
+
@reflection.cache
|
|
289
|
+
def get_pk_constraint(self, connection, table_name, schema=None, **kw):
|
|
290
|
+
"""Return the primary key constraint for *table_name*."""
|
|
291
|
+
constraint_name = None
|
|
292
|
+
constrained_columns = []
|
|
293
|
+
|
|
294
|
+
quoted = self.identifier_preparer.quote_identifier(table_name)
|
|
295
|
+
result = connection.execute(text(f"SHOW COLUMNS IN {quoted}"))
|
|
296
|
+
for row in result:
|
|
297
|
+
if row[3] == "PRI":
|
|
298
|
+
constrained_columns.append(row[0])
|
|
299
|
+
|
|
300
|
+
# Try to find constraint name from db_constraint
|
|
301
|
+
if constrained_columns:
|
|
302
|
+
try:
|
|
303
|
+
constraint_result = connection.execute(
|
|
304
|
+
text(
|
|
305
|
+
"SELECT index_name FROM db_constraint "
|
|
306
|
+
"WHERE class_name = :table AND type = 0"
|
|
307
|
+
),
|
|
308
|
+
{"table": table_name},
|
|
309
|
+
)
|
|
310
|
+
row = constraint_result.fetchone()
|
|
311
|
+
if row:
|
|
312
|
+
constraint_name = row[0]
|
|
313
|
+
except Exception: # nosec B110 — constraint name is optional metadata
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"name": constraint_name,
|
|
318
|
+
"constrained_columns": constrained_columns,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@reflection.cache
|
|
322
|
+
def get_foreign_keys(self, connection, table_name, schema=None, **kw):
|
|
323
|
+
"""Return foreign key information for *table_name*.
|
|
324
|
+
|
|
325
|
+
Uses ``db_constraint`` system table to retrieve FK constraints.
|
|
326
|
+
"""
|
|
327
|
+
foreign_keys = []
|
|
328
|
+
try:
|
|
329
|
+
result = connection.execute(
|
|
330
|
+
text(
|
|
331
|
+
"SELECT c.constraint_name, c.class_name, "
|
|
332
|
+
"a.attr_name, c.ref_class_name, ra.attr_name "
|
|
333
|
+
"FROM db_constraint c "
|
|
334
|
+
"JOIN _db_index_key a ON c.index_name = a.index_name "
|
|
335
|
+
"LEFT JOIN db_constraint rc ON c.ref_class_name = rc.class_name "
|
|
336
|
+
" AND rc.type = 0 "
|
|
337
|
+
"LEFT JOIN _db_index_key ra ON rc.index_name = ra.index_name "
|
|
338
|
+
" AND a.key_order = ra.key_order "
|
|
339
|
+
"WHERE c.class_name = :table AND c.type = 3 "
|
|
340
|
+
"ORDER BY c.constraint_name, a.key_order"
|
|
341
|
+
),
|
|
342
|
+
{"table": table_name},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
fk_dict: dict[str, dict] = {}
|
|
346
|
+
for row in result:
|
|
347
|
+
name = row[0]
|
|
348
|
+
if name not in fk_dict:
|
|
349
|
+
fk_dict[name] = {
|
|
350
|
+
"name": name,
|
|
351
|
+
"constrained_columns": [],
|
|
352
|
+
"referred_schema": schema,
|
|
353
|
+
"referred_table": row[3],
|
|
354
|
+
"referred_columns": [],
|
|
355
|
+
}
|
|
356
|
+
fk_dict[name]["constrained_columns"].append(row[2])
|
|
357
|
+
if row[4]:
|
|
358
|
+
fk_dict[name]["referred_columns"].append(row[4])
|
|
359
|
+
|
|
360
|
+
foreign_keys = list(fk_dict.values())
|
|
361
|
+
except Exception: # nosec B110 — graceful fallback when FK info unavailable
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
return foreign_keys
|
|
365
|
+
|
|
366
|
+
@reflection.cache
|
|
367
|
+
def get_table_names(self, connection, schema=None, **kw):
|
|
368
|
+
"""Return a list of table names for *schema*."""
|
|
369
|
+
if schema is not None:
|
|
370
|
+
return []
|
|
371
|
+
result = connection.execute(
|
|
372
|
+
text(
|
|
373
|
+
"SELECT class_name FROM db_class "
|
|
374
|
+
"WHERE class_type = 'CLASS' AND is_system_class = 'NO'"
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
return [row[0] for row in result]
|
|
378
|
+
|
|
379
|
+
@reflection.cache
|
|
380
|
+
def get_view_names(self, connection, schema=None, **kw):
|
|
381
|
+
"""Return a list of view names."""
|
|
382
|
+
result = connection.execute(
|
|
383
|
+
text("SELECT class_name FROM db_class WHERE class_type = 'VCLASS'")
|
|
384
|
+
)
|
|
385
|
+
return [row[0] for row in result]
|
|
386
|
+
|
|
387
|
+
@reflection.cache
|
|
388
|
+
def get_view_definition(self, connection, view_name, schema=None, **kw):
|
|
389
|
+
"""Return the CREATE VIEW definition."""
|
|
390
|
+
quoted = self.identifier_preparer.quote_identifier(view_name)
|
|
391
|
+
result = connection.execute(text(f"SHOW CREATE VIEW {quoted}"))
|
|
392
|
+
row = result.fetchone()
|
|
393
|
+
return row[1] if row else None
|
|
394
|
+
|
|
395
|
+
@reflection.cache
|
|
396
|
+
def get_indexes(self, connection, table_name, schema=None, **kw):
|
|
397
|
+
"""Return index information for *table_name*."""
|
|
398
|
+
idict: dict[str, dict] = {}
|
|
399
|
+
|
|
400
|
+
# Batch-fetch primary key flags for all indexes on this table
|
|
401
|
+
# instead of issuing one query per index (N+1 → 1+1).
|
|
402
|
+
pk_indexes: set[str] = set()
|
|
403
|
+
try:
|
|
404
|
+
pk_result = connection.execute(
|
|
405
|
+
text(
|
|
406
|
+
"SELECT index_name, is_primary_key FROM _db_index "
|
|
407
|
+
"WHERE class_of.class_name = :table"
|
|
408
|
+
),
|
|
409
|
+
{"table": table_name},
|
|
410
|
+
)
|
|
411
|
+
for pk_row in pk_result:
|
|
412
|
+
if pk_row[1]:
|
|
413
|
+
pk_indexes.add(pk_row[0])
|
|
414
|
+
except Exception:
|
|
415
|
+
# Fallback: if catalog query fails, pk_indexes stays empty
|
|
416
|
+
# so no indexes will be wrongly excluded.
|
|
417
|
+
log.debug("Batch PK query failed for table %s, falling back", table_name)
|
|
418
|
+
|
|
419
|
+
quoted = self.identifier_preparer.quote_identifier(table_name)
|
|
420
|
+
result = connection.execute(text(f"SHOW INDEXES IN {quoted}"))
|
|
421
|
+
for row in result:
|
|
422
|
+
index_name = row[2]
|
|
423
|
+
|
|
424
|
+
if index_name not in pk_indexes:
|
|
425
|
+
if index_name in idict:
|
|
426
|
+
idict[index_name]["column_names"].append(row[4])
|
|
427
|
+
else:
|
|
428
|
+
idict[index_name] = {
|
|
429
|
+
"name": index_name,
|
|
430
|
+
"column_names": [row[4]],
|
|
431
|
+
"unique": row[1] == 0,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return list(idict.values())
|
|
435
|
+
|
|
436
|
+
@reflection.cache
|
|
437
|
+
def get_unique_constraints(self, connection, table_name, schema=None, **kw):
|
|
438
|
+
"""Return unique constraints for *table_name*."""
|
|
439
|
+
unique_constraints = []
|
|
440
|
+
try:
|
|
441
|
+
result = connection.execute(
|
|
442
|
+
text(
|
|
443
|
+
"SELECT c.constraint_name, a.attr_name "
|
|
444
|
+
"FROM db_constraint c "
|
|
445
|
+
"JOIN _db_index_key a ON c.index_name = a.index_name "
|
|
446
|
+
"WHERE c.class_name = :table AND c.type = 1 "
|
|
447
|
+
"ORDER BY c.constraint_name, a.key_order"
|
|
448
|
+
),
|
|
449
|
+
{"table": table_name},
|
|
450
|
+
)
|
|
451
|
+
uc_dict: dict[str, dict] = {}
|
|
452
|
+
for row in result:
|
|
453
|
+
name = row[0]
|
|
454
|
+
if name not in uc_dict:
|
|
455
|
+
uc_dict[name] = {"name": name, "column_names": []}
|
|
456
|
+
uc_dict[name]["column_names"].append(row[1])
|
|
457
|
+
unique_constraints = list(uc_dict.values())
|
|
458
|
+
except Exception: # nosec B110 — graceful fallback when UC info unavailable
|
|
459
|
+
pass
|
|
460
|
+
return unique_constraints
|
|
461
|
+
|
|
462
|
+
@reflection.cache
|
|
463
|
+
def get_check_constraints(self, connection, table_name, schema=None, **kw):
|
|
464
|
+
"""Return check constraints for *table_name*."""
|
|
465
|
+
# CUBRID does not expose check constraint expressions easily
|
|
466
|
+
return []
|
|
467
|
+
|
|
468
|
+
@reflection.cache
|
|
469
|
+
def get_table_comment(self, connection, table_name, schema=None, **kw):
|
|
470
|
+
"""Return table comment from CUBRID system catalog."""
|
|
471
|
+
result = connection.execute(
|
|
472
|
+
text("SELECT comment FROM db_class WHERE class_name = :name"),
|
|
473
|
+
{"name": table_name},
|
|
474
|
+
)
|
|
475
|
+
row = result.fetchone()
|
|
476
|
+
return {"text": row[0] if row and row[0] else None}
|
|
477
|
+
|
|
478
|
+
def get_schema_names(self, connection, **kw):
|
|
479
|
+
"""Return schema names. CUBRID does not support schemas."""
|
|
480
|
+
return []
|
|
481
|
+
|
|
482
|
+
def has_table(self, connection, table_name, schema=None, **kw):
|
|
483
|
+
"""Check if *table_name* exists."""
|
|
484
|
+
result = connection.execute(
|
|
485
|
+
text(
|
|
486
|
+
"SELECT COUNT(*) FROM db_class "
|
|
487
|
+
"WHERE class_type IN ('CLASS', 'VCLASS') "
|
|
488
|
+
"AND is_system_class = 'NO' "
|
|
489
|
+
"AND class_name = :name"
|
|
490
|
+
),
|
|
491
|
+
{"name": table_name},
|
|
492
|
+
)
|
|
493
|
+
return result.scalar() > 0
|
|
494
|
+
|
|
495
|
+
def has_index(self, connection, table_name, index_name, schema=None):
|
|
496
|
+
"""Check if an index exists on *table_name*."""
|
|
497
|
+
try:
|
|
498
|
+
result = connection.execute(
|
|
499
|
+
text("SELECT COUNT(*) FROM _db_index WHERE index_name = :name"),
|
|
500
|
+
{"name": index_name},
|
|
501
|
+
)
|
|
502
|
+
return result.scalar() > 0
|
|
503
|
+
except Exception:
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
def has_sequence(self, connection, sequence_name, schema=None, **kw):
|
|
507
|
+
"""CUBRID does not support sequences."""
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
# ----- Connection lifecycle -----
|
|
511
|
+
|
|
512
|
+
def on_connect(self):
|
|
513
|
+
"""Return a callable to set up a new DBAPI connection.
|
|
514
|
+
|
|
515
|
+
Disables autocommit on the CUBRID driver so that
|
|
516
|
+
SQLAlchemy can manage transactions properly.
|
|
517
|
+
"""
|
|
518
|
+
isolation_level = self.isolation_level
|
|
519
|
+
|
|
520
|
+
def connect(conn):
|
|
521
|
+
# CUBRID Python driver defaults to autocommit=True;
|
|
522
|
+
# SA manages transactions, so we turn it off.
|
|
523
|
+
conn.set_autocommit(False)
|
|
524
|
+
if isolation_level is not None:
|
|
525
|
+
self.set_isolation_level(conn, isolation_level)
|
|
526
|
+
|
|
527
|
+
return connect
|
|
528
|
+
|
|
529
|
+
def _get_server_version_info(self, connection):
|
|
530
|
+
"""Return server version as a tuple of ints."""
|
|
531
|
+
versions = connection.execute(text("SELECT VERSION()")).scalar()
|
|
532
|
+
m = re.match(r"(\d+)\.(\d+)\.(\d+)\.(\d+)", versions)
|
|
533
|
+
if m:
|
|
534
|
+
return tuple(int(x) for x in m.group(1, 2, 3, 4))
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
def _get_default_schema_name(self, connection):
|
|
538
|
+
"""Return the default schema name."""
|
|
539
|
+
return connection.execute(text("SELECT SCHEMA()")).scalar()
|
|
540
|
+
|
|
541
|
+
# ----- Isolation level -----
|
|
542
|
+
|
|
543
|
+
# CUBRID isolation level mapping
|
|
544
|
+
# https://www.cubrid.org/manual/en/11.0/sql/transaction.html
|
|
545
|
+
_ISOLATION_LEVEL_MAP: dict[str, int] = {
|
|
546
|
+
"SERIALIZABLE": 6,
|
|
547
|
+
"REPEATABLE READ": 5,
|
|
548
|
+
"REPEATABLE READ SCHEMA, REPEATABLE READ INSTANCES": 5,
|
|
549
|
+
"READ COMMITTED": 4,
|
|
550
|
+
"REPEATABLE READ SCHEMA, READ COMMITTED INSTANCES": 4,
|
|
551
|
+
"CURSOR STABILITY": 4,
|
|
552
|
+
"REPEATABLE READ SCHEMA, READ UNCOMMITTED INSTANCES": 3,
|
|
553
|
+
"READ COMMITTED SCHEMA, READ COMMITTED INSTANCES": 2,
|
|
554
|
+
"READ COMMITTED SCHEMA, READ UNCOMMITTED INSTANCES": 1,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
_ISOLATION_LEVEL_REVERSE: dict[int, str] = {
|
|
558
|
+
6: "SERIALIZABLE",
|
|
559
|
+
5: "REPEATABLE READ SCHEMA, REPEATABLE READ INSTANCES",
|
|
560
|
+
4: "REPEATABLE READ SCHEMA, READ COMMITTED INSTANCES",
|
|
561
|
+
3: "REPEATABLE READ SCHEMA, READ UNCOMMITTED INSTANCES",
|
|
562
|
+
2: "READ COMMITTED SCHEMA, READ COMMITTED INSTANCES",
|
|
563
|
+
1: "READ COMMITTED SCHEMA, READ UNCOMMITTED INSTANCES",
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
def get_isolation_level(self, dbapi_conn):
|
|
567
|
+
"""Return the current isolation level for *dbapi_conn*."""
|
|
568
|
+
# https://www.cubrid.org/manual/en/11.0/sql/transaction.html
|
|
569
|
+
cursor = dbapi_conn.cursor()
|
|
570
|
+
cursor.execute("GET TRANSACTION ISOLATION LEVEL TO X")
|
|
571
|
+
cursor.execute("SELECT X")
|
|
572
|
+
val = cursor.fetchone()[0]
|
|
573
|
+
cursor.close()
|
|
574
|
+
# CUBRID returns numeric level; map to string for SA
|
|
575
|
+
if isinstance(val, int):
|
|
576
|
+
return self._ISOLATION_LEVEL_REVERSE.get(val, str(val))
|
|
577
|
+
return val
|
|
578
|
+
|
|
579
|
+
def get_isolation_level_values(self):
|
|
580
|
+
"""Return the list of valid isolation level values."""
|
|
581
|
+
return [
|
|
582
|
+
"SERIALIZABLE",
|
|
583
|
+
"REPEATABLE READ",
|
|
584
|
+
"REPEATABLE READ SCHEMA, REPEATABLE READ INSTANCES",
|
|
585
|
+
"READ COMMITTED",
|
|
586
|
+
"REPEATABLE READ SCHEMA, READ COMMITTED INSTANCES",
|
|
587
|
+
"CURSOR STABILITY",
|
|
588
|
+
"REPEATABLE READ SCHEMA, READ UNCOMMITTED INSTANCES",
|
|
589
|
+
"READ COMMITTED SCHEMA, READ COMMITTED INSTANCES",
|
|
590
|
+
"READ COMMITTED SCHEMA, READ UNCOMMITTED INSTANCES",
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
def set_isolation_level(self, dbapi_conn, level):
|
|
594
|
+
"""Set the isolation level for *dbapi_conn*."""
|
|
595
|
+
# Note: do NOT unwrap dbapi_conn.connection — the inner C-level
|
|
596
|
+
# _cubrid.connection cursor cannot handle SET TRANSACTION SQL.
|
|
597
|
+
# SA already passes the correct Python-level CUBRIDdb.connections.Connection.
|
|
598
|
+
# Map string level to numeric
|
|
599
|
+
numeric_level = self._ISOLATION_LEVEL_MAP.get(level.upper())
|
|
600
|
+
if numeric_level is None:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
f"Invalid isolation level: {level!r}. "
|
|
603
|
+
f"Valid values: {list(self._ISOLATION_LEVEL_MAP.keys())}"
|
|
604
|
+
)
|
|
605
|
+
cursor = dbapi_conn.cursor()
|
|
606
|
+
cursor.execute(f"SET TRANSACTION ISOLATION LEVEL {numeric_level}")
|
|
607
|
+
cursor.execute("COMMIT")
|
|
608
|
+
cursor.close()
|
|
609
|
+
|
|
610
|
+
def reset_isolation_level(self, dbapi_conn):
|
|
611
|
+
"""Revert isolation level to the default."""
|
|
612
|
+
# CUBRID server default is typically level 4
|
|
613
|
+
# (REPEATABLE READ SCHEMA, READ COMMITTED INSTANCES)
|
|
614
|
+
self.set_isolation_level(dbapi_conn, "READ COMMITTED")
|
|
615
|
+
|
|
616
|
+
def do_release_savepoint(self, connection, name):
|
|
617
|
+
"""CUBRID does not support RELEASE SAVEPOINT; no-op."""
|
|
618
|
+
pass
|
|
619
|
+
|
|
620
|
+
# ----- Error handling & connection health -----
|
|
621
|
+
|
|
622
|
+
# Disconnect message patterns (lowercase) for is_disconnect().
|
|
623
|
+
# Modeled after psycopg2's string-based approach since CUBRIDdb has
|
|
624
|
+
# only Error, InterfaceError, DatabaseError, and NotSupportedError.
|
|
625
|
+
_disconnect_messages = (
|
|
626
|
+
"connection is closed",
|
|
627
|
+
"closed connection",
|
|
628
|
+
"lost connection",
|
|
629
|
+
"server has gone away",
|
|
630
|
+
"connection reset",
|
|
631
|
+
"broken pipe",
|
|
632
|
+
"cannot communicate with the broker",
|
|
633
|
+
"received invalid packet",
|
|
634
|
+
"broker is not available",
|
|
635
|
+
"communication error",
|
|
636
|
+
"connection timed out",
|
|
637
|
+
"connection refused",
|
|
638
|
+
"connection was killed",
|
|
639
|
+
"failed to connect",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def is_disconnect(self, e, connection, cursor):
|
|
643
|
+
"""Return True if *e* indicates a dropped connection.
|
|
644
|
+
|
|
645
|
+
CUBRID's Python driver exposes a limited exception hierarchy:
|
|
646
|
+
``Error``, ``InterfaceError``, ``DatabaseError``, and
|
|
647
|
+
``NotSupportedError``. There is no ``OperationalError`` class,
|
|
648
|
+
so we rely primarily on string-based message matching (similar
|
|
649
|
+
to psycopg2) supplemented by known numeric error codes.
|
|
650
|
+
"""
|
|
651
|
+
if isinstance(e, self.dbapi.Error):
|
|
652
|
+
msg = str(e).lower()
|
|
653
|
+
for pattern in self._disconnect_messages:
|
|
654
|
+
if pattern in msg:
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
# Check numeric error code for known disconnect codes
|
|
658
|
+
error_code = self._extract_error_code(e)
|
|
659
|
+
if error_code is not None and error_code in (
|
|
660
|
+
-21003, # CAS_ER_COMMUNICATION
|
|
661
|
+
-21005, # CAS_ER_COMMUNICATION (alternate)
|
|
662
|
+
-10005, # ER_NET_CANT_CONNECT
|
|
663
|
+
-10007, # ER_NET_SERVER_COMM_ERROR
|
|
664
|
+
):
|
|
665
|
+
return True
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def _extract_error_code(exception) -> Optional[int]:
|
|
670
|
+
"""Extract a numeric error code from a CUBRID DBAPI exception.
|
|
671
|
+
|
|
672
|
+
CUBRIDdb stores the error code in ``exception.args[0]``.
|
|
673
|
+
Returns ``None`` if no numeric code can be extracted.
|
|
674
|
+
"""
|
|
675
|
+
if exception.args:
|
|
676
|
+
first_arg = exception.args[0]
|
|
677
|
+
if isinstance(first_arg, int):
|
|
678
|
+
return first_arg
|
|
679
|
+
# Some errors embed the code at the start: "-21003 ..."
|
|
680
|
+
if isinstance(first_arg, str):
|
|
681
|
+
parts = first_arg.split(None, 1)
|
|
682
|
+
if parts:
|
|
683
|
+
try:
|
|
684
|
+
return int(parts[0])
|
|
685
|
+
except (ValueError, IndexError):
|
|
686
|
+
pass
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
def do_ping(self, dbapi_connection):
|
|
690
|
+
"""Ping the server to check connection liveness.
|
|
691
|
+
|
|
692
|
+
Used by SQLAlchemy's ``pool_pre_ping`` feature. The CUBRID
|
|
693
|
+
Python driver exposes a ``ping()`` method on the connection
|
|
694
|
+
that delegates to the C-level CCI ping.
|
|
695
|
+
"""
|
|
696
|
+
dbapi_connection.ping()
|
|
697
|
+
return True
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
dialect = CubridDialect
|