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.
@@ -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