sqlalchemy-cubrid 1.2.1__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {sqlalchemy_cubrid-1.2.1/sqlalchemy_cubrid.egg-info → sqlalchemy_cubrid-1.2.3}/PKG-INFO +2 -1
  2. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/pyproject.toml +2 -1
  3. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/__init__.py +1 -1
  4. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/alembic_impl.py +40 -0
  5. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/dialect.py +94 -66
  6. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3/sqlalchemy_cubrid.egg-info}/PKG-INFO +2 -1
  7. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid.egg-info/requires.txt +1 -0
  8. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_aio_integration.py +8 -23
  9. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_alembic.py +40 -0
  10. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_dialect_offline.py +74 -21
  11. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/AUTHORS +0 -0
  12. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/LICENSE +0 -0
  13. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/README.md +0 -0
  14. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/setup.cfg +0 -0
  15. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/aio_pycubrid_dialect.py +0 -0
  16. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/base.py +0 -0
  17. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/compiler.py +0 -0
  18. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/dml.py +0 -0
  19. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/py.typed +0 -0
  20. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/pycubrid_dialect.py +0 -0
  21. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/requirements.py +0 -0
  22. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/trace.py +0 -0
  23. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid/types.py +0 -0
  24. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid.egg-info/SOURCES.txt +0 -0
  25. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid.egg-info/dependency_links.txt +0 -0
  26. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid.egg-info/entry_points.txt +0 -0
  27. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/sqlalchemy_cubrid.egg-info/top_level.txt +0 -0
  28. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_aio_pycubrid_dialect.py +0 -0
  29. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_base.py +0 -0
  30. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_compiler.py +0 -0
  31. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_dialects.py +0 -0
  32. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_integration.py +0 -0
  33. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_json.py +0 -0
  34. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_pycubrid_dialect.py +0 -0
  35. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_requirements.py +0 -0
  36. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_suite.py +0 -0
  37. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_trace.py +0 -0
  38. {sqlalchemy_cubrid-1.2.1 → sqlalchemy_cubrid-1.2.3}/test/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cubrid
3
- Version: 1.2.1
3
+ Version: 1.2.3
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
@@ -35,6 +35,7 @@ Requires-Dist: CUBRID-Python; extra == "cubrid"
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: pytest>=7.0; extra == "dev"
37
37
  Requires-Dist: pytest-cov; extra == "dev"
38
+ Requires-Dist: pytest-asyncio; extra == "dev"
38
39
  Requires-Dist: ruff==0.15.8; extra == "dev"
39
40
  Requires-Dist: mypy==1.19.1; extra == "dev"
40
41
  Requires-Dist: bandit[toml]==1.9.4; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sqlalchemy-cubrid"
7
- version = "1.2.1"
7
+ version = "1.2.3"
8
8
  description = "CUBRID dialect for SQLAlchemy"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -42,6 +42,7 @@ cubrid = [
42
42
  dev = [
43
43
  "pytest>=7.0",
44
44
  "pytest-cov",
45
+ "pytest-asyncio",
45
46
  "ruff==0.15.8",
46
47
  "mypy==1.19.1",
47
48
  "bandit[toml]==1.9.4",
@@ -52,7 +52,7 @@ from sqlalchemy.sql.sqltypes import (
52
52
  TIMESTAMP,
53
53
  )
54
54
 
55
- __version__ = "1.2.1"
55
+ __version__ = "1.2.3"
56
56
 
57
57
  __all__ = (
58
58
  "insert",
@@ -76,6 +76,15 @@ class CubridImpl(DefaultImpl):
76
76
 
77
77
  _collection_type_names: set[str] = {"SET", "MULTISET", "SEQUENCE"}
78
78
 
79
+ # CUBRID's STRING / CLOB / Text are stored physically as
80
+ # VARCHAR(1073741823). When SQLAlchemy's reflection round-trips a
81
+ # ``Text`` / ``CLOB`` / ``STRING`` column it sees a VARCHAR with that
82
+ # exact length, which trips Alembic's default compare_type into
83
+ # reporting a spurious type change on every autogenerate run
84
+ # (see cubrid-labs/sqlalchemy-cubrid#120).
85
+ _CUBRID_UNBOUNDED_VARCHAR_LENGTH: int = 1073741823
86
+ _unbounded_string_type_names: set[str] = {"TEXT", "CLOB", "STRING"}
87
+
79
88
  @staticmethod
80
89
  def _normalize_collection_value(value: object) -> str:
81
90
  if isinstance(value, str):
@@ -115,6 +124,11 @@ class CubridImpl(DefaultImpl):
115
124
  inspector_type = inspector_column.type
116
125
  metadata_type = metadata_column.type
117
126
 
127
+ # Suppress false-positive Text/CLOB/STRING vs VARCHAR(max) diffs.
128
+ # See ``_CUBRID_UNBOUNDED_VARCHAR_LENGTH`` docstring above.
129
+ if self._is_unbounded_string_match(inspector_type, metadata_type):
130
+ return False
131
+
118
132
  inspector_name = inspector_type.__class__.__name__
119
133
  metadata_name = metadata_type.__class__.__name__
120
134
 
@@ -143,3 +157,29 @@ class CubridImpl(DefaultImpl):
143
157
  return inspector_values != metadata_values
144
158
 
145
159
  return set(inspector_values) != set(metadata_values)
160
+
161
+ @classmethod
162
+ def _is_unbounded_string_match(
163
+ cls, inspector_type: TypeEngine[Any], metadata_type: TypeEngine[Any]
164
+ ) -> bool:
165
+ """Return ``True`` when one side is an unbounded string type and the other is a VARCHAR sized to CUBRID's STRING maximum length."""
166
+ return cls._matches_unbounded_pair(
167
+ inspector_type, metadata_type
168
+ ) or cls._matches_unbounded_pair(metadata_type, inspector_type)
169
+
170
+ @classmethod
171
+ def _matches_unbounded_pair(
172
+ cls, varchar_side: TypeEngine[Any], unbounded_side: TypeEngine[Any]
173
+ ) -> bool:
174
+ if varchar_side.__class__.__name__ != "VARCHAR":
175
+ return False
176
+ if getattr(varchar_side, "length", None) != cls._CUBRID_UNBOUNDED_VARCHAR_LENGTH:
177
+ return False
178
+ unbounded_name = unbounded_side.__class__.__name__.upper()
179
+ if unbounded_name in cls._unbounded_string_type_names:
180
+ return True
181
+ # Plain SQLAlchemy String() with no length declared also maps to
182
+ # VARCHAR(1073741823) on CUBRID.
183
+ if unbounded_name == "STRING" or unbounded_name.endswith("STRING"):
184
+ return getattr(unbounded_side, "length", None) is None
185
+ return False
@@ -78,6 +78,29 @@ _RE_TYPE_PARAMS = re.compile(r"\([\d,]+\)")
78
78
  _RE_LENGTH = re.compile(r"\((\d+)\)")
79
79
  _RE_PRECISION_SCALE = re.compile(r"\((\d+)(?:,\s*(\d+))?\)")
80
80
 
81
+ # CUBRID's ``SHOW CREATE TABLE`` emits foreign-key clauses such as::
82
+ #
83
+ # CONSTRAINT [fk_name] FOREIGN KEY ([col1], [col2]) REFERENCES
84
+ # [owner.ref_table] ([rcol1], [rcol2]) ON DELETE ... ON UPDATE ...
85
+ #
86
+ # We parse this DDL fragment because CUBRID exposes no queryable view that
87
+ # carries the referenced table/columns alongside the constraint name.
88
+ _RE_FOREIGN_KEY = re.compile(
89
+ r"CONSTRAINT\s+\[(?P<name>[^\]]+)\]\s+FOREIGN\s+KEY\s*"
90
+ r"\((?P<cols>[^)]+)\)\s+REFERENCES\s+"
91
+ r"\[(?P<ref_table>[^\]]+)\]\s*\((?P<ref_cols>[^)]+)\)",
92
+ re.IGNORECASE,
93
+ )
94
+ # Parses ``CONSTRAINT [name] UNIQUE KEY ([col1], [col2])`` from
95
+ # ``SHOW CREATE TABLE`` output. Same rationale as ``_RE_FOREIGN_KEY`` —
96
+ # CUBRID's ``db_constraint`` view is not queryable in 11.x.
97
+ _RE_UNIQUE_KEY = re.compile(
98
+ r"CONSTRAINT\s+\[(?P<name>[^\]]+)\]\s+UNIQUE\s+KEY\s*"
99
+ r"\((?P<cols>[^)]+)\)",
100
+ re.IGNORECASE,
101
+ )
102
+ _RE_BRACKET_IDENT = re.compile(r"\[([^\]]+)\]")
103
+
81
104
 
82
105
  # -----------------------------------------------------------------------
83
106
  # Column-spec and ischema_names mappings
@@ -374,45 +397,43 @@ class CubridDialect(default.DefaultDialect):
374
397
  ) -> list[ReflectedForeignKeyConstraint]:
375
398
  """Return foreign key information for *table_name*.
376
399
 
377
- Uses ``db_constraint`` system table to retrieve FK constraints.
400
+ Parses ``SHOW CREATE TABLE`` output to extract FK constraints.
401
+ CUBRID exposes no queryable ``db_constraint`` view (despite older
402
+ documentation referencing it), so the DDL string is the only
403
+ reliable source for FK metadata that includes the referenced table
404
+ and columns. See cubrid-labs/sqlalchemy-cubrid#120.
378
405
  """
379
406
  foreign_keys: list[ReflectedForeignKeyConstraint] = []
380
407
  try:
381
- result = connection.execute(
382
- text(
383
- "SELECT c.constraint_name, c.class_name, "
384
- "a.attr_name, c.ref_class_name, ra.attr_name "
385
- "FROM db_constraint c "
386
- "JOIN _db_index_key a ON c.index_name = a.index_name "
387
- "LEFT JOIN db_constraint rc ON c.ref_class_name = rc.class_name "
388
- " AND rc.type = 0 "
389
- "LEFT JOIN _db_index_key ra ON rc.index_name = ra.index_name "
390
- " AND a.key_order = ra.key_order "
391
- "WHERE c.class_name = :table AND c.type = 3 "
392
- "ORDER BY c.constraint_name, a.key_order"
393
- ),
394
- {"table": table_name},
408
+ quoted = self.identifier_preparer.quote_identifier(table_name)
409
+ result = connection.execute(text(f"SHOW CREATE TABLE {quoted}"))
410
+ row = result.fetchone()
411
+ except Exception: # nosec B110 — graceful fallback when DDL unavailable
412
+ return foreign_keys
413
+ if row is None:
414
+ return foreign_keys
415
+ ddl = str(row[1]) if len(row) > 1 else str(row[0])
416
+ for fk_match in _RE_FOREIGN_KEY.finditer(ddl):
417
+ constraint_name = fk_match.group("name")
418
+ constrained_columns = [
419
+ col.strip() for col in _RE_BRACKET_IDENT.findall(fk_match.group("cols"))
420
+ ]
421
+ ref_table_raw = fk_match.group("ref_table")
422
+ # CUBRID prefixes referenced tables with the owner (e.g.
423
+ # ``dba.budget_categories``) — strip it for SQLAlchemy.
424
+ ref_table = ref_table_raw.split(".", 1)[-1]
425
+ referred_columns = [
426
+ col.strip() for col in _RE_BRACKET_IDENT.findall(fk_match.group("ref_cols"))
427
+ ]
428
+ foreign_keys.append(
429
+ {
430
+ "name": constraint_name,
431
+ "constrained_columns": constrained_columns,
432
+ "referred_schema": schema,
433
+ "referred_table": ref_table,
434
+ "referred_columns": referred_columns,
435
+ }
395
436
  )
396
-
397
- fk_dict: dict[str, ReflectedForeignKeyConstraint] = {}
398
- for row in result:
399
- name = row[0]
400
- if name not in fk_dict:
401
- fk_dict[name] = {
402
- "name": name,
403
- "constrained_columns": [],
404
- "referred_schema": schema,
405
- "referred_table": row[3],
406
- "referred_columns": [],
407
- }
408
- fk_dict[name]["constrained_columns"].append(row[2])
409
- if row[4]:
410
- fk_dict[name]["referred_columns"].append(row[4])
411
-
412
- foreign_keys = list(fk_dict.values())
413
- except Exception: # nosec B110 — graceful fallback when FK info unavailable
414
- pass
415
-
416
437
  return foreign_keys
417
438
 
418
439
  @reflection.cache
@@ -469,31 +490,43 @@ class CubridDialect(default.DefaultDialect):
469
490
  """Return index information for *table_name*."""
470
491
  idict: dict[str, ReflectedIndex] = {}
471
492
 
472
- # Batch-fetch primary key flags for all indexes on this table
473
- # instead of issuing one query per index (N+1 1+1).
493
+ # Batch-fetch primary-key and foreign-key flags for all indexes on
494
+ # this table from CUBRID's ``_db_index`` catalog (single query for
495
+ # both, instead of N+1 lookups).
496
+ #
497
+ # PK indexes are filtered because SQLAlchemy reports the PK via
498
+ # ``get_pk_constraint`` separately. FK indexes are filtered because
499
+ # CUBRID auto-creates an index for every foreign key (with the same
500
+ # name as the FK constraint) and these are an implementation detail
501
+ # — if reported they cause Alembic autogenerate to emit spurious
502
+ # ``op.drop_index`` / ``op.create_index`` diffs on every run.
503
+ # See cubrid-labs/sqlalchemy-cubrid#120.
474
504
  pk_indexes: set[str] = set()
505
+ fk_indexes: set[str] = set()
475
506
  try:
476
- pk_result = connection.execute(
507
+ flag_result = connection.execute(
477
508
  text(
478
- "SELECT index_name, is_primary_key FROM _db_index "
479
- "WHERE class_of.class_name = :table"
509
+ "SELECT index_name, is_primary_key, is_foreign_key "
510
+ "FROM _db_index WHERE class_of.class_name = :table"
480
511
  ),
481
512
  {"table": table_name},
482
513
  )
483
- for pk_row in pk_result:
484
- if pk_row[1]:
485
- pk_indexes.add(pk_row[0])
514
+ for flag_row in flag_result:
515
+ if flag_row[1]:
516
+ pk_indexes.add(flag_row[0])
517
+ if flag_row[2]:
518
+ fk_indexes.add(flag_row[0])
486
519
  except Exception:
487
- # Fallback: if catalog query fails, pk_indexes stays empty
488
- # so no indexes will be wrongly excluded.
489
- log.debug("Batch PK query failed for table %s, falling back", table_name)
520
+ # Fallback: if the catalog query fails, both sets stay empty so
521
+ # no indexes will be wrongly excluded.
522
+ log.debug("Batch index-flag query failed for table %s, falling back", table_name)
490
523
 
491
524
  quoted = self.identifier_preparer.quote_identifier(table_name)
492
525
  result = connection.execute(text(f"SHOW INDEXES IN {quoted}"))
493
526
  for row in result:
494
527
  index_name = row[2]
495
528
 
496
- if index_name not in pk_indexes:
529
+ if index_name not in pk_indexes and index_name not in fk_indexes:
497
530
  if index_name in idict:
498
531
  idict[index_name]["column_names"].append(row[4])
499
532
  else:
@@ -516,25 +549,20 @@ class CubridDialect(default.DefaultDialect):
516
549
  """Return unique constraints for *table_name*."""
517
550
  unique_constraints: list[ReflectedUniqueConstraint] = []
518
551
  try:
519
- result = connection.execute(
520
- text(
521
- "SELECT c.constraint_name, a.attr_name "
522
- "FROM db_constraint c "
523
- "JOIN _db_index_key a ON c.index_name = a.index_name "
524
- "WHERE c.class_name = :table AND c.type = 1 "
525
- "ORDER BY c.constraint_name, a.key_order"
526
- ),
527
- {"table": table_name},
528
- )
529
- uc_dict: dict[str, ReflectedUniqueConstraint] = {}
530
- for row in result:
531
- name = row[0]
532
- if name not in uc_dict:
533
- uc_dict[name] = {"name": name, "column_names": []}
534
- uc_dict[name]["column_names"].append(row[1])
535
- unique_constraints = list(uc_dict.values())
536
- except Exception: # nosec B110 — graceful fallback when UC info unavailable
537
- pass
552
+ quoted = self.identifier_preparer.quote_identifier(table_name)
553
+ result = connection.execute(text(f"SHOW CREATE TABLE {quoted}"))
554
+ row = result.fetchone()
555
+ except Exception: # nosec B110 — graceful fallback when DDL unavailable
556
+ return unique_constraints
557
+ if row is None:
558
+ return unique_constraints
559
+ ddl = str(row[1]) if len(row) > 1 else str(row[0])
560
+ for uc_match in _RE_UNIQUE_KEY.finditer(ddl):
561
+ constraint_name = uc_match.group("name")
562
+ column_names = [
563
+ col.strip() for col in _RE_BRACKET_IDENT.findall(uc_match.group("cols"))
564
+ ]
565
+ unique_constraints.append({"name": constraint_name, "column_names": column_names})
538
566
  return unique_constraints
539
567
 
540
568
  @reflection.cache
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cubrid
3
- Version: 1.2.1
3
+ Version: 1.2.3
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
@@ -35,6 +35,7 @@ Requires-Dist: CUBRID-Python; extra == "cubrid"
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: pytest>=7.0; extra == "dev"
37
37
  Requires-Dist: pytest-cov; extra == "dev"
38
+ Requires-Dist: pytest-asyncio; extra == "dev"
38
39
  Requires-Dist: ruff==0.15.8; extra == "dev"
39
40
  Requires-Dist: mypy==1.19.1; extra == "dev"
40
41
  Requires-Dist: bandit[toml]==1.9.4; extra == "dev"
@@ -9,6 +9,7 @@ CUBRID-Python
9
9
  [dev]
10
10
  pytest>=7.0
11
11
  pytest-cov
12
+ pytest-asyncio
12
13
  ruff==0.15.8
13
14
  mypy==1.19.1
14
15
  bandit[toml]==1.9.4
@@ -27,8 +27,7 @@ import os
27
27
  import pytest
28
28
  import pytest_asyncio
29
29
  from sqlalchemy import Column, Integer, MetaData, String, Table, text
30
- from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
31
- from sqlalchemy.orm import sessionmaker
30
+ from sqlalchemy.ext.asyncio import create_async_engine
32
31
 
33
32
  # ---------------------------------------------------------------------------
34
33
  # Fixtures
@@ -129,13 +128,9 @@ class TestAsyncCRUD:
129
128
  async def test_update(self, engine, metadata):
130
129
  users = metadata.tables["aio_test_users"]
131
130
  async with engine.begin() as conn:
132
- await conn.execute(
133
- users.update().where(users.c.name == "alice").values(value=100)
134
- )
131
+ await conn.execute(users.update().where(users.c.name == "alice").values(value=100))
135
132
  async with engine.connect() as conn:
136
- result = await conn.execute(
137
- users.select().where(users.c.name == "alice")
138
- )
133
+ result = await conn.execute(users.select().where(users.c.name == "alice"))
139
134
  row = result.fetchone()
140
135
  assert row is not None and row.value == 100
141
136
 
@@ -144,26 +139,20 @@ class TestAsyncCRUD:
144
139
  async with engine.begin() as conn:
145
140
  await conn.execute(users.delete().where(users.c.name == "bob"))
146
141
  async with engine.connect() as conn:
147
- result = await conn.execute(
148
- users.select().where(users.c.name == "bob")
149
- )
142
+ result = await conn.execute(users.select().where(users.c.name == "bob"))
150
143
  assert result.fetchone() is None
151
144
 
152
145
  async def test_transaction_rollback(self, engine, metadata):
153
146
  users = metadata.tables["aio_test_users"]
154
147
  try:
155
148
  async with engine.begin() as conn:
156
- await conn.execute(
157
- users.insert().values(name="will_rollback", value=999)
158
- )
149
+ await conn.execute(users.insert().values(name="will_rollback", value=999))
159
150
  raise RuntimeError("force rollback")
160
151
  except RuntimeError:
161
152
  pass
162
153
 
163
154
  async with engine.connect() as conn:
164
- result = await conn.execute(
165
- users.select().where(users.c.name == "will_rollback")
166
- )
155
+ result = await conn.execute(users.select().where(users.c.name == "will_rollback"))
167
156
  assert result.fetchone() is None
168
157
 
169
158
  async def test_concurrent_pool(self, engine):
@@ -243,9 +232,7 @@ class TestAsyncJSON:
243
232
 
244
233
  async def test_null_json(self, engine):
245
234
  async with engine.begin() as conn:
246
- await conn.execute(
247
- text("INSERT INTO aio_test_json (payload) VALUES (NULL)")
248
- )
235
+ await conn.execute(text("INSERT INTO aio_test_json (payload) VALUES (NULL)"))
249
236
  assert await self._last_json(engine) is None
250
237
 
251
238
  async def test_empty_object(self, engine):
@@ -258,9 +245,7 @@ class TestAsyncJSON:
258
245
 
259
246
  async def test_json_extract(self, engine):
260
247
  async with engine.connect() as conn:
261
- r = await conn.execute(
262
- text("SELECT JSON_EXTRACT('{\"a\": 1}', '$.a')")
263
- )
248
+ r = await conn.execute(text("SELECT JSON_EXTRACT('{\"a\": 1}', '$.a')"))
264
249
  assert r.scalar() is not None
265
250
 
266
251
  async def test_orm_json_type(self, engine):
@@ -259,3 +259,43 @@ class TestCubridImplAutogenerate:
259
259
  metadata_column = sa.Column("v", cubrid_types.MULTISET("a", "b"))
260
260
 
261
261
  assert impl.compare_type(inspector_column, metadata_column) is True
262
+
263
+ def test_compare_type_text_vs_varchar_max_no_diff(self):
264
+ """Text() vs VARCHAR(1073741823) must NOT be reported as a type change.
265
+
266
+ See cubrid-labs/sqlalchemy-cubrid#120 — CUBRID stores Text/CLOB/STRING
267
+ as VARCHAR(1073741823) so reflection round-trips trip Alembic's
268
+ default compare_type.
269
+ """
270
+ from sqlalchemy_cubrid.alembic_impl import CubridImpl
271
+
272
+ impl = object.__new__(CubridImpl)
273
+ inspector_column = sa.Column("v", cubrid_types.VARCHAR(1073741823))
274
+ for metadata_type in (sa.Text(), cubrid_types.CLOB(), cubrid_types.STRING()):
275
+ metadata_column = sa.Column("v", metadata_type)
276
+ assert impl.compare_type(inspector_column, metadata_column) is False
277
+ # Reverse direction must also hold.
278
+ assert impl.compare_type(metadata_column, inspector_column) is False
279
+
280
+ def test_compare_type_varchar_max_vs_string_no_length_no_diff(self):
281
+ """Plain String() (no length) maps to VARCHAR(max) on CUBRID."""
282
+ from sqlalchemy_cubrid.alembic_impl import CubridImpl
283
+
284
+ impl = object.__new__(CubridImpl)
285
+ inspector_column = sa.Column("v", cubrid_types.VARCHAR(1073741823))
286
+ metadata_column = sa.Column("v", sa.String())
287
+ assert impl.compare_type(inspector_column, metadata_column) is False
288
+
289
+ def test_compare_type_varchar_bounded_still_compared(self):
290
+ """VARCHAR(100) vs Text() must still be detected as a real diff."""
291
+ from alembic.ddl.impl import DefaultImpl
292
+
293
+ from sqlalchemy_cubrid.alembic_impl import CubridImpl
294
+
295
+ impl = object.__new__(CubridImpl)
296
+ inspector_column = sa.Column("v", cubrid_types.VARCHAR(100))
297
+ metadata_column = sa.Column("v", sa.Text())
298
+ with mock.patch.object(DefaultImpl, "compare_type", return_value=True) as m:
299
+ result = impl.compare_type(inspector_column, metadata_column)
300
+ assert result is True
301
+ m.assert_called_once()
@@ -338,14 +338,24 @@ class TestReflectionMethods:
338
338
  def test_get_foreign_keys_success_and_exception(self):
339
339
  dialect = CubridDialect()
340
340
 
341
+ ddl = (
342
+ "CREATE TABLE [orders] (\n"
343
+ " [id] INTEGER NOT NULL,\n"
344
+ " CONSTRAINT [pk_orders] PRIMARY KEY ([id]),\n"
345
+ " CONSTRAINT [fk_order_user] FOREIGN KEY ([user_id], [tenant_id]) "
346
+ "REFERENCES [dba.users] ([id], [tenant_id]) "
347
+ "ON DELETE RESTRICT ON UPDATE RESTRICT,\n"
348
+ " CONSTRAINT [fk_order_legacy] FOREIGN KEY ([legacy_id]) "
349
+ "REFERENCES [legacy] ([id])\n"
350
+ ")"
351
+ )
352
+
341
353
  success_conn = MagicMock()
342
354
  success_conn.info_cache = {}
343
355
  success_conn.dialect_options = {}
344
- success_conn.execute.return_value = [
345
- ("fk_order_user", "orders", "user_id", "users", "id"),
346
- ("fk_order_user", "orders", "tenant_id", "users", "tenant_id"),
347
- ("fk_no_ref_col", "orders", "legacy_id", "legacy", None),
348
- ]
356
+ success_result = MagicMock()
357
+ success_result.fetchone.return_value = ("orders", ddl)
358
+ success_conn.execute.return_value = success_result
349
359
 
350
360
  fks = _invoke_reflection(
351
361
  dialect,
@@ -358,12 +368,14 @@ class TestReflectionMethods:
358
368
  assert len(fks) == 2
359
369
  first = next(item for item in fks if item["name"] == "fk_order_user")
360
370
  assert first["constrained_columns"] == ["user_id", "tenant_id"]
371
+ # Owner prefix (``dba.``) must be stripped from the referenced table.
361
372
  assert first["referred_table"] == "users"
362
373
  assert first["referred_columns"] == ["id", "tenant_id"]
363
374
  assert first["referred_schema"] == "main"
364
375
 
365
- second = next(item for item in fks if item["name"] == "fk_no_ref_col")
366
- assert second["referred_columns"] == []
376
+ second = next(item for item in fks if item["name"] == "fk_order_legacy")
377
+ assert second["referred_table"] == "legacy"
378
+ assert second["referred_columns"] == ["id"]
367
379
 
368
380
  failed_conn = MagicMock()
369
381
  failed_conn.info_cache = {}
@@ -420,11 +432,13 @@ class TestReflectionMethods:
420
432
  connection.info_cache = {}
421
433
  connection.dialect_options = {}
422
434
 
423
- # Batch PK query returns index_name, is_primary_key pairs
424
- pk_batch_result = [
425
- ("uq_name", 0),
426
- ("pk_users", 1),
427
- ("idx_email", 0),
435
+ # Single batch query returns (index_name, is_primary_key, is_foreign_key)
436
+ # tuples for every index on the table. PK and FK auto-indexes are
437
+ # filtered from the SHOW INDEXES output.
438
+ flag_rows = [
439
+ ("uq_name", 0, 0),
440
+ ("pk_users", 1, 0),
441
+ ("idx_email", 0, 0),
428
442
  ]
429
443
 
430
444
  show_indexes_rows = [
@@ -435,7 +449,7 @@ class TestReflectionMethods:
435
449
  ]
436
450
 
437
451
  connection.execute.side_effect = [
438
- pk_batch_result, # batch PK query
452
+ flag_rows, # batch _db_index query
439
453
  show_indexes_rows, # SHOW INDEXES
440
454
  ]
441
455
 
@@ -447,7 +461,7 @@ class TestReflectionMethods:
447
461
  ]
448
462
 
449
463
  def test_get_indexes_batch_pk_query_failure(self):
450
- """When the batch PK catalog query fails, all indexes are returned."""
464
+ """When the batch catalog query fails, all indexes are returned."""
451
465
  dialect = CubridDialect()
452
466
  connection = MagicMock()
453
467
  connection.info_cache = {}
@@ -459,28 +473,67 @@ class TestReflectionMethods:
459
473
  ]
460
474
 
461
475
  connection.execute.side_effect = [
462
- RuntimeError("catalog unavailable"), # batch PK query fails
476
+ RuntimeError("catalog unavailable"), # batch flag query fails
463
477
  show_indexes_rows, # SHOW INDEXES
464
478
  ]
465
479
 
466
480
  indexes = _invoke_reflection(dialect, "get_indexes", connection, "users")
467
481
 
468
- # Both indexes returned since PK detection failed gracefully
482
+ # Both indexes returned since PK/FK detection failed gracefully.
469
483
  assert len(indexes) == 2
470
484
  assert indexes[0]["name"] == "uq_name"
471
485
  assert indexes[1]["name"] == "pk_users"
472
486
 
487
+ def test_get_indexes_excludes_fk_auto_indexes(self):
488
+ """FK auto-indexes (``_db_index.is_foreign_key`` true) are filtered.
489
+
490
+ See cubrid-labs/sqlalchemy-cubrid#120 — otherwise Alembic
491
+ autogenerate emits spurious drop_index/create_index diffs.
492
+ """
493
+ dialect = CubridDialect()
494
+ connection = MagicMock()
495
+ connection.info_cache = {}
496
+ connection.dialect_options = {}
497
+
498
+ flag_rows = [
499
+ ("fk_orders_user", 0, 1),
500
+ ("fk_orders_product", 0, 1),
501
+ ("idx_orders_status", 0, 0),
502
+ ]
503
+
504
+ connection.execute.side_effect = [
505
+ flag_rows,
506
+ [
507
+ (None, 1, "fk_orders_user", None, "user_id"),
508
+ (None, 1, "fk_orders_product", None, "product_id"),
509
+ (None, 1, "idx_orders_status", None, "status"),
510
+ ],
511
+ ]
512
+
513
+ indexes = _invoke_reflection(dialect, "get_indexes", connection, "orders")
514
+
515
+ assert indexes == [
516
+ {"name": "idx_orders_status", "column_names": ["status"], "unique": False},
517
+ ]
518
+
473
519
  def test_get_unique_constraints_success_and_exception(self):
474
520
  dialect = CubridDialect()
475
521
 
522
+ ddl = (
523
+ "CREATE TABLE [users] (\n"
524
+ " [id] INTEGER NOT NULL,\n"
525
+ " CONSTRAINT [pk_users] PRIMARY KEY ([id]),\n"
526
+ " CONSTRAINT [uq_users_email] UNIQUE KEY ([email], [tenant_id]),\n"
527
+ " CONSTRAINT [uq_users_name] UNIQUE KEY ([name])\n"
528
+ ")"
529
+ )
530
+
476
531
  success_conn = MagicMock()
477
532
  success_conn.info_cache = {}
478
533
  success_conn.dialect_options = {}
479
- success_conn.execute.return_value = [
480
- ("uq_users_email", "email"),
481
- ("uq_users_email", "tenant_id"),
482
- ("uq_users_name", "name"),
483
- ]
534
+ success_result = MagicMock()
535
+ success_result.fetchone.return_value = ("users", ddl)
536
+ success_conn.execute.return_value = success_result
484
537
 
485
538
  unique_constraints = _invoke_reflection(
486
539
  dialect,