Skip to content

v1.8.0

Latest

Choose a tag to compare

@github-actions github-actions released this 28 Sep 18:49
· 55 commits to main since this release
78bcbc7

Upgrade notes

Behavior changes you may notice (details in the entries below):

  • Alembic upgrades are atomic by default (#503). CUBRID DDL is transactional, so a failed multi-revision alembic upgrade now rolls back the whole run, including the alembic_version update. Set transaction_per_migration=True in context.configure() to keep per-revision commits (recommended for long or large-table migrations). Offline --sql scripts no longer contain BEGIN;; run them with csql --no-auto-commit --no-single-line so a failing statement stops the script.
  • Alembic registration (#504). CubridImpl is registered when the dialect loads, so a default env.py works without importing sqlalchemy_cubrid.alembic_impl. The [alembic] and [dev] extras now require alembic>=1.7.2; cubrid+aiopycubrid:// online migrations need Alembic's async template (alembic init -t async).
  • Reflection. Unique constraints are no longer reported twice and reflect as unique indexes, as on MySQL (#529); non-DBA users get complete metadata from the public catalog views (#549); a missing table or view raises NoSuchTableError (#530); has_table() / has_index() match mixed-case names (#543); get_foreign_keys() is sorted by name (#531); BIT(n) / BIT VARYING(n) keep their length (#545); a failing catalog query raises instead of silently returning incomplete metadata (#533, #549). Autogenerate also detects Text ↔ String(n) changes (#544). Review the first Alembic autogenerate diff after upgrading.
  • DDL and types. UnicodeText and TEXT compile to STRING (#534); BINARY(n) / VARBINARY(n) compile to BIT(n*8) / BIT VARYING(n*8) and UUID to CHAR(32) (#545); Index.drop() and op.drop_index() emit DROP INDEX <name> ON <table>, so op.drop_index() needs table_name (#533); if_exists=True on index drops and if_not_exists=True on index creates raise CompileError (#533, #540), as does an explicit String(0) (#440); Boolean is_() / is_not() against a value render with <=> (#465); JSON as_numeric(p, s) returns Decimal (#535).
  • Transactions. isolation_level="AUTOCOMMIT" works on every driver, invalid levels raise ArgumentError, and dialect.on_connect() no longer applies isolation_level (a hand-built pool must apply it) (#501). Engine- and connection-level isolation levels survive commit() / rollback() on pycubrid (#505). On cubrid://, executemany runs each parameter set separately: None values and rowcounts are correct, large batches are slower (#502).
  • Async. driver_connection on cubrid+aiopycubrid:// is now the pycubrid.aio connection; use dbapi_connection for the SQLAlchemy adapter (#520). Async LargeBinary / BLOB binding works (#500).
  • Removed the unreachable SQLAlchemy 1.x hooks should_autocommit_text(), AUTOCOMMIT_REGEXP and the dbapi() classmethods (#462).
  • pycubrid 1.8.0 is recommended. It raises IntegrityError for NOT NULL and foreign-key violations, raises instead of silently returning a truncated result after commit() / rollback(), reports correct null_ok and collection type codes in cursor.description, and keeps the CAS session across commits and rollbacks except when the CAS itself is restarted (docs/DRIVER_COMPAT.md Known Issue 10); the pycubrid contract tests require it (#480, #481, #482). The declared range stays pycubrid>=1.3.2,<2.0 (raising it is tracked in #559), so the dialect's isolation-level re-apply workaround is kept for older pycubrid and is a harmless no-op on 1.8.0.

Added

  • Upstream canary failures are reported as an issue (#521) — .github/workflows/upstream-canary.yml keeps its canary jobs non-blocking (continue-on-error, schedule and manual dispatch only), so a failing run against pycubrid@main used to pass silently. A new report job runs after both canary jobs on scheduled and dispatched runs of the default branch (dispatches from feature branches never touch the shared issue, and cancelled runs are not reported): on failure it opens an issue titled "Upstream canary failing against pycubrid@main" with the ci label, or comments on the open one instead of creating a duplicate, listing each job's outcome, the run URL, the pycubrid commit tested and the sqlalchemy-cubrid ref and commit; once both jobs pass again it closes that issue with a comment. Each canary job now resolves pycubrid@main with git ls-remote, installs that exact commit and exposes it and its own status as job outputs, because job-level continue-on-error hides failures from needs.<job>.result. The workflow defaults to contents: read; only the report job has issues: write, and it uses the gh CLI with GITHUB_TOKEN (no third-party action), runs one at a time (concurrency), and has no continue-on-error, so a failure to report turns the run red. docs/DEVELOPMENT.md (+ Korean) describes the reporting.
  • Blocking SQLAlchemy compliance lanes for released pycubrid (#463) — the official SQLAlchemy compliance suite now gates merges for cubrid+pycubrid:// as well as CUBRIDdb, as steps of the existing integration-tests cells (no new job or CUBRID service): lane pycubrid@sa2.1 (pycubrid 1.7.1, SQLAlchemy 2.1.1) on Python 3.14 × CUBRID 11.4 and lane pycubrid@sa2.0 (pycubrid 1.7.1, SQLAlchemy 2.0.53) on Python 3.10 × CUBRID 10.2, both pinned and both recording versions via scripts/report_driver_versions.py; the CUBRIDdb lane (cubrid@sa2.0, CUBRID 11.4) is unchanged. test/known_failures.txt is now keyed per lane: every entry names the <driver>@sa<major.minor> lanes (optionally @cubrid<major.minor> for a server-specific failure) it fails in and is strict-xfailed only there, so a failure baselined for one driver cannot hide a regression on the other. With CUBRID_STRICT_KNOWN_FAILURES=1 a strict XPASS, a stale entry, a listed entry that is skipped instead of xfailed, a lane with no reviewed baseline, or a SQLAlchemy release other than the lane's pinned one fails the run; a malformed entry, a duplicated node id or a server-narrowed tag outside the (lane, server) pairs CI gates is a load error. The pycubrid compliance step runs even when the CUBRIDdb lane failed. A new offline test/test_known_failures.py validates the manifest format. docs/DEVELOPMENT.md and docs/SUPPORT_MATRIX.md (+ Korean) describe the lanes and the baseline update process.
  • Required driver-differential lane with an all-skipped guard and version record (#486) — the regular and full integration jobs now run test/test_driver_differential.py with pycubrid and CUBRIDdb and CUBRID_REQUIRE_DRIVER_DIFFERENTIAL=1; with that variable set, test/conftest.py fails the session when no comparison ran and passed, so a lane where every case skipped or none was collected no longer reports success. Local runs without the variable still skip cleanly. scripts/report_driver_versions.py records the exact Python, SQLAlchemy, pycubrid, CUBRIDdb and CUBRID server versions in the job log and the GitHub step summary. New differential cases cover Core executemany with integer, UTF-8/CJK and NULL values, textual executemany with integer and UTF-8/CJK values (CUBRIDdb 11.3.0.51 reuses the previous row's value for a NULL parameter there), scalar binds, textual-SQL result column names, and commit/rollback visibility; contract areas blocked upstream are tracked in #480–#484 (#480 and #481 have since added differential cases). docs/DEVELOPMENT.md now states that a specific pycubrid release candidate must pass the downstream contract suite before the pycubrid>=1.3.2,<2.0 bound is widened, while the weekly pycubrid@main canary stays non-blocking. Tests and CI only; no dialect behavior change.
  • Live BLOB/CLOB value-contract tests on released drivers (#485) — test_integration.py (CUBRIDdb and cubrid+pycubrid:// lanes) and test_aio_integration.py (cubrid+aiopycubrid://) now round-trip LargeBinary, BLOB, CLOB and Text through Core and ORM with small, Unicode/CJK, NULL and 256 KiB payloads (larger than pycubrid's ~80 KB LOB_READ chunk), and assert the returned value is bytes/str, never a driver LOB handle. Verified on CUBRID 10.2 and 11.4: writes store the full value and NULL/Text round-trip, but non-NULL BLOB/CLOB reads return a driver LOB locator (pycubrid dict handle, CUBRIDdb 'file:...' string), and async LargeBinary/BLOB binding fails because the async DB-API adapter lacks Binary. Those cases are strict per-driver xfails; docs/TYPES.md, docs/DRIVER_COMPAT.md and docs/SUPPORT_MATRIX.md document the current behavior (reflection is unaffected), and the CLOB type row no longer lists sqlalchemy.Text, which compiles to STRING. No dialect behavior changes.
  • Live tests that results are never silently truncated across commit or rollback (#481) — test_integration.py (CUBRIDdb and cubrid+pycubrid:// lanes) and test_aio_integration.py read a 500-row, 1000-byte-per-row result (more than pycubrid's 100-row FETCH batch; the broker's first response holds ~16 rows) with fetchone(), fetchmany() and fetchall() after commit(), rollback() or no boundary, and require every row or a DB-API error, never a successful partial Result. The driver-differential suite adds the rollback case for both drivers. Verified on CUBRID 10.2 and 11.4: CUBRIDdb 11.3.0.51 returns all rows after commit and raises InterfaceError after rollback; pycubrid main raises InterfaceError; released pycubrid 1.7.1 silently returns only the buffered rows on a warm connection (cubrid-lab/pycubrid#395), so those sync cases are strict xfails for pycubrid only via test/pycubrid_upstream.py (no-op under CUBRID_PYCUBRID_UPSTREAM=1, set by the pycubrid@main integration canary; see docs/DEVELOPMENT.md). Async results pass on every driver because AsyncConnection.execute() buffers the whole result. docs/DRIVER_COMPAT.md documents the per-driver behavior. Tests, CI and docs only; no dialect behavior change.
  • Live IntegrityError contract tests for constraint violations (#480) — test_integration.py (CUBRIDdb and cubrid+pycubrid:// lanes), test_aio_integration.py (cubrid+aiopycubrid://) and the driver-differential suite now check through Core and ORM that NOT NULL, foreign-key and unique/primary-key (control) violations raise sqlalchemy.exc.IntegrityError, and that the same connection or Session runs new statements after rollback(). Verified on CUBRID 10.2 and 11.4: CUBRIDdb 11.3.0.51 and pycubrid main pass every case; released pycubrid 1.7.1 raises NOT NULL (-631) and foreign-key (-922) violations as DatabaseError (cubrid-lab/pycubrid#390), so those cases are strict xfails for the pycubrid drivers only. The new test/pycubrid_upstream.py helper applies such xfails unless CUBRID_PYCUBRID_UPSTREAM=1, which the weekly pycubrid@main integration canary sets; docs/DEVELOPMENT.md documents it and when to remove it. docs/DRIVER_COMPAT.md documents the per-driver exception classes. Tests, CI and docs only; no dialect behavior change.
  • Stronger IntegrityError contract tests (#480) — the Core, ORM, async and driver-differential constraint-violation tests now prove same-connection reuse: the error does not invalidate the connection, and after rollback() the same Connection (and, for the ORM, a Session bound to it) keeps the same DBAPI connection. Before the pycubrid-only xfail is applied, they also assert the native server error code (-631 NOT NULL, -922 foreign key, -670 unique; pycubrid Error.code, CUBRIDdb args[0]), and the class assertion also checks that exc.orig is the driver's IntegrityError. A patched is_disconnect() that always returns True now fails these tests. The is_disconnect() docstring no longer claims CUBRIDdb lacks OperationalError. Tests and docs only; no behavior change.
  • Stronger result-completeness tests (#481) — the sync test now asserts that CUBRIDdb raises sqlalchemy.exc.InterfaceError after rollback (and no error otherwise). It reads the FETCH batch size from pycubrid's Connection signature instead of hardcoding 100. Every fetched prefix must be in order with intact payloads before the pycubrid#395 xfail applies, and the marker now covers only the completeness assertion; an explicit error still turns into a strict XPASS on a fixed build. The driver-differential case also checks that pycubrid's first response does not hold the whole result. A new async case drives the lazily fetching pycubrid.aio cursor directly (execute, fetch one, commit or rollback, fetch the rest); released pycubrid 1.7.1 silently returns 16 of 500 rows there, so it is a strict xfail for pycubrid#395. docs/DRIVER_COMPAT.md Known Issue 8 (en/ko) records the raw async cursor behavior. Tests and docs only; no behavior change.
  • Live cursor.description contract tests (#482) — test_integration.py (CUBRIDdb and cubrid+pycubrid:// lanes) and test_aio_integration.py (cubrid+aiopycubrid://) check the subset SQLAlchemy exposes: Result.keys() versus description names for text() aliases/expressions and Core select(), representative scalar type codes, null_ok for nullable and NOT NULL columns, SET/MULTISET/SEQUENCE type codes, reflected nullability, and sync/async pycubrid agreement; the driver-differential suite adds a scalar-description comparison. Verified on CUBRID 10.2 and 11.4: CUBRIDdb 11.3.0.51 and pycubrid main pass (CUBRIDdb reports collection columns with CCI composite codes such as 40 for SET(INTEGER), recorded per driver); released pycubrid 1.7.1 reports null_ok inverted (cubrid-lab/pycubrid#431) and collection columns as their element type (cubrid-lab/pycubrid#430), so those cases are strict xfails for pycubrid only via test/pycubrid_upstream.py (no-op under CUBRID_PYCUBRID_UPSTREAM=1, set by the pycubrid@main integration canary; see docs/DEVELOPMENT.md). docs/FEATURE_SUPPORT.md documents that the dialect passes cursor.description through without normalization. Tests, CI and docs only; no dialect behavior change.
  • Review follow-ups for the #480/#481 contract tests — the result-completeness checks (sync, raw async cursor and driver-differential) now validate the order and payload of every returned row, including the one consumed by fetchone(), before the pycubrid#395 xfail applies. The driver-differential constraint-violation case now also asserts that exc.orig is the driver's own IntegrityError for CUBRIDdb and, behind the existing pycubrid#390 marker, for pycubrid. Tests only; no behavior change.

Changed

  • Contract tests now require the pycubrid 1.8.0 fixes (#480, #481, #482) — the strict xfail_unreleased_pycubrid_fix markers for cubrid-lab/pycubrid#390 (NOT NULL / foreign-key violations raise IntegrityError), #395 (no silently truncated result after commit/rollback), #430 (collection cursor.description type codes) and #431 (null_ok not inverted) are removed from test_integration.py, test_aio_integration.py and test_driver_differential.py, so those assertions run unconditionally on the pycubrid lanes and require pycubrid 1.8.0 or later. test/pycubrid_upstream.py and the CUBRID_PYCUBRID_UPSTREAM switch in upstream-canary.yml are removed; DRIVER_COMPAT.md Known Issues 8 and 9 and FEATURE_SUPPORT.md now mark these pycubrid bugs as fixed in 1.8.0. The pycubrid>=1.3.2,<2.0 dependency bound is unchanged.
  • Alembic: CUBRID DDL is transactional, so the whole upgrade is now atomic by default (#503) — CubridImpl.transactional_ddl is now True (was False). The docs and docstrings claimed CUBRID implicitly commits DDL; it does not. With client autocommit off, which the dialect always sets, ROLLBACK undoes CREATE TABLE, ALTER TABLE, DROP TABLE, TRUNCATE, CREATE INDEX (including WITH ONLINE [PARALLEL n]), CREATE VIEW, CREATE SERIAL and RENAME TABLE, and DDL never commits pending DML; client autocommit is the only auto-commit behavior. Behavior change: a failed alembic upgrade now rolls back every revision of that run, including the alembic_version update, instead of keeping the revisions that finished before the failure (each revision was already atomic on its own, because Alembic's online mode wraps it in connection.begin()), and context.is_transactional_ddl() returns True. Set transaction_per_migration=True in context.configure() to keep per-revision commits; it is recommended for long or large-table migrations, because uncommitted DDL holds schema locks (other sessions wait on SCH_S_LOCK). CubridImpl.emit_begin() now emits nothing, because CUBRID has no BEGIN statement (csql rejects it), so offline --sql scripts contain no BEGIN; and end each transaction with COMMIT;; run them with csql --no-auto-commit --no-single-line (csql's default single-line mode continues past a failing statement, still runs the trailing COMMIT; and exits 0). New live tests in test/test_transactional_ddl.py (run in the PR and full integration matrices with CUBRID_REQUIRE_TRANSACTIONAL_DDL=1, which turns an unavailable driver, server or csql into a failure instead of a skip) prove, on CUBRID 10.2 and 11.4 with pycubrid and CUBRIDdb, that each of those statements rolls back, that DDL does not commit earlier DML and that metadata.create_all() rolls back; that a failing multi-revision Alembic upgrade leaves no partial schema and no version row, while transaction_per_migration=True keeps the completed revision; and that offline --sql output runs in csql. README (and translations), docs/ALEMBIC.md, docs/TROUBLESHOOTING.md, docs/ISOLATION_LEVELS.md, docs/SUPPORT_MATRIX.md, docs/ARCHITECTURE.md, docs/ORM_COOKBOOK.md (+ Korean), docs/PRD.md, AGENTS.md, llms.txt and scripts/alembic_safety_check.py no longer describe DDL as auto-committed or recommend one DDL operation per revision.

Removed

  • Removed the dead SQLAlchemy 1.x should_autocommit_text() hook, its AUTOCOMMIT_REGEXP, and the legacy dbapi() classmethods (#462) — SQLAlchemy 2.0 and 2.1 DefaultExecutionContext no longer define or call should_autocommit_text(), so the regex it consulted never affected a supported runtime (this also retires #439's request to add REPLACE to it, surfaced by @biggdawg320 in #468: there is no longer a regex to be inconsistent with the dialect's REPLACE INTO construct). create_engine() only falls back to a dbapi() classmethod when the dialect class does not define import_dbapi() itself, and CubridDialect, PyCubridDialect and PyCubridAsyncDialect all do, so the dbapi() wrappers were unreachable. Transactions are unchanged: DML and DDL text commits only via conn.commit(), engine.begin() or a Session. Added offline regression tests asserting the hooks stay gone, that engine creation resolves the DBAPI via import_dbapi() without a deprecation warning, and that DELETE/REPLACE/CREATE text is rolled back unless explicitly committed; docs/CONNECTION.md, docs/FEATURE_SUPPORT.md, docs/DML_EXTENSIONS.md, docs/SUPPORT_MATRIX.md (+ Korean) and docs/PRD.md no longer describe the non-existent statement-text autocommit detection.

Fixed

  • Alembic autogenerate detects Text ↔ String(n) changes (#544) — CubridImpl matched the reflected VARCHAR(1073741823) of a STRING column by upper-cased class name, and the dialect's STRING type shares that name with the generic sa.String, so a model column changed from Text to String(10) produced no diff and the migration that shrinks the column was never generated. The dialect's STRING is now matched by class; Text, UnicodeText, CLOB and a String() without a length still match a reflected STRING column, while String(n) goes through Alembic's normal length comparison. Live autogenerate tests on both drivers cover both directions (Text → String(10) and String(10) → Text are detected, applied, and compare clean afterwards) and the unbounded types that must not report a diff.
  • BINARY, VARBINARY and UUID create columns CUBRID accepts (#545) — sqlalchemy.BINARY, VARBINARY and UUID fell through to SQLAlchemy's generic BINARY / VARBINARY / UUID DDL, which CUBRID 10.2 and 11.4 reject, so a model using them could not be created. BINARY(n) now compiles to BIT(n*8) (BINARY() to BIT(8)) and VARBINARY(n) to BIT VARYING(n*8) (VARBINARY() to BIT VARYING); both drivers bind and return the values as bytes, and a zero length raises CompileError. UUID compiles to CHAR(32) like Uuid, with SQLAlchemy's non-native UUID handling for as_uuid=True and False. Reflection now keeps the length and VARYING of BIT(n) / BIT VARYING(n) columns (they reflected as BIT(1)), so these columns compile back to the same DDL and Alembic autogenerate reports no false type change. A new compiler test sweeps every generic SQLAlchemy type and fails if one compiles to a type name that CUBRID rejects in CREATE TABLE (list taken from live runs on 10.2 and 11.4) or to one not yet checked live. docs/TYPES.md (+ Korean) documents the mappings and fixes the CHAR VARYING reflection rows, which said NVARCHAR although it reflects as VARCHAR.
  • Boolean IS / IS NOT predicates compile to SQL CUBRID accepts (#465) — without a native BOOLEAN, SQLAlchemy renders col.is_(True), is_(False), is_not(True) and is_not(False) (also with true()/false(), a bound parameter, or any expression such as (col == 5).is_(True)) as IS 1 / IS 0 / IS NOT 1 / IS NOT 0, which CUBRID rejects with a syntax error on 10.2 and 11.4: its IS accepts only [NOT] NULL and [NOT] TRUE/FALSE, and since 11.2 IS TRUE also needs a logical operand (b IS TRUE fails for a SMALLINT column), while (x = 1) IS NOT TRUE is rejected in a SELECT list. CubridCompiler now renders IS against a value with the null-safe equal operator, like IS [NOT] DISTINCT FROM: a IS b as a <=> b and a IS NOT b as (a <=> b) = 0, which keeps the SQL semantics exactly (IS TRUE is never NULL; IS NOT TRUE is its complement) in WHERE clauses and SELECT lists; IS [NOT] NULL is unchanged. col == True, not_(col), true()/false() and and_()/or_() already compiled to valid SQL (col = 1, col = 0, 1 = 1) and are unchanged; CUBRID itself rejects logical operators (AND, OR, NOT) in a SELECT list, which is documented. New compiler tests and a live truth-table test (stored 1, 0 and NULL; WHERE and projection) on CUBRID 10.2 and 11.4 with CUBRIDdb and pycubrid cover it; docs/TYPES.md and docs/FEATURE_SUPPORT.md (+ Korean) document the rendering and its semantics.
  • Reflection works for non-DBA users: read the public catalog views instead of the DBA-only _db_* tables (#549) — _db_index, _db_index_key and _db_attribute (and _db_class) are readable only by DBA on CUBRID 10.2, 11.0, 11.2 and 11.4; for any other user a query fails with SELECT is not authorized on _db_index (-494). The dialect swallowed that error, so reflection silently degraded: get_indexes() reported the primary-key index and the foreign-key auto-indexes as ordinary indexes (spurious Alembic autogenerate drop_index/create_index diffs, the #120 problem), get_pk_constraint() fell back to SHOW COLUMNS and lost the PK name and every column of a composite key after the first (#426), get_unique_constraints() fell back to SHOW CREATE TABLE parsing, get_columns() returned no column comments, and has_index() raised. has_index() keeps the name matching and owner preference from #543 on the public view. These methods now read the public db_index, db_index_key and db_attribute views, which every user can read (flags are 'YES'/'NO' there). Since CUBRID 11.2 those views also list same-named classes of other owners that the user may read, so the rows are limited to the owner the cached db_class lookup from #529 prefers (the current user's class first); before 11.2 class names are global and the views have no owner_name column. This also stops a same-named class of another owner from adding its indexes or comments on 11.2+. The catalog lookups also match the table name as given or folded to lower case, like the db_class lookup and SHOW ... IN <name>: get_indexes("Users") used to report the PK and FK auto-indexes (and get_pk_constraint() lost composite columns, get_columns() the comments) even for DBA. A failing catalog query now raises instead of silently falling back to incomplete metadata; get_pk_constraint() still uses SHOW COLUMNS and get_unique_constraints() still parses SHOW CREATE TABLE when the catalog has no matching row, and get_unique_constraints() maps an Unknown class error from its SHOW INDEXES step to NoSuchTableError. A new live test reflects a table with a named composite PK, a foreign key, a unique constraint, an index and a column comment as DBA and as a newly created user, by its name and in upper case, on CUBRID 10.2 and 11.4 with both drivers (on 11.2+ with a same-named decoy table of the other user). docs/FEATURE_SUPPORT.md, docs/ARCHITECTURE.md (+ Korean) and docs/PRD.md describe the catalog sources.
  • has_table() and has_index() find mixed-case names, so checkfirst=True no longer silently skips (#543) — CUBRID stores identifiers in lower case, even quoted ones, but both methods compared the names exactly as passed in. For Index("IX_Mixed543", Table("Users543", ...)), has_index() returned False, so Index.drop(checkfirst=True) did nothing and the index stayed; has_table() had the same gap. Both now also match the lower-case form (IN (:name, LOWER(:name))), like _get_class_type() and the other reflection methods, and has_index() resolves the table with the same owner preference (the current user's own class first, which matters on CUBRID 11.2+ where owners can share a class name). @reflection.cache and error propagation (#444, #538) are unchanged. Live tests cover mixed-case checkfirst create and drop on CUBRID 10.2 and 11.4 with both drivers.
  • CreateIndex(..., if_not_exists=True) raises CompileError instead of emitting CREATE INDEX IF NOT EXISTS (#540) — CUBRID 10.2, 11.0, 11.2 and 11.4 reject CREATE INDEX IF NOT EXISTS (and CREATE UNIQUE INDEX IF NOT EXISTS) with a syntax error, but the DDL compiler emitted it for Index(...) / CreateIndex(..., if_not_exists=True) and Alembic op.create_index(..., if_not_exists=True). CubridDDLCompiler.visit_create_index now refuses it with a CompileError that points to inspect(conn).has_index() and Index.create(checkfirst=True), mirroring DROP INDEX IF EXISTS (#533). A plain CREATE INDEX is unchanged. docs/ALEMBIC.md and docs/FEATURE_SUPPORT.md (+ Korean) document the limitation.
  • Reflecting a missing table or view raises NoSuchTableError (#530) — get_foreign_keys() and get_unique_constraints() returned [] for an object that does not exist (the SHOW CREATE TABLE fallbacks swallowed the server's Unknown class error with a WARNING traceback), get_table_comment() returned {"text": None}, and get_pk_constraint() and get_view_definition() let the raw -493 ProgrammingError escape. Because SQLAlchemy's default get_multi_*() implementations leave out only the names whose single-object method raises NoSuchTableError, the multi variants also reported the missing object. get_foreign_keys() and get_unique_constraints() now look up the class type first (the cached, owner-aware db_class lookup from #529): no row raises NoSuchTableError, and a view (VCLASS) returns [] without running the table-only SHOW CREATE TABLE, which removes the WARNING traceback logged for every reflected view. get_table_comment() raises when db_class has no row, and matches the name like the class-type lookup (as given or lower-cased, the current user's class first). get_pk_constraint() (SHOW COLUMNS fallback), get_view_definition() (SHOW CREATE VIEW) and the SHOW CREATE TABLE fallbacks of get_foreign_keys() / get_unique_constraints() map an error to NoSuchTableError only when its message is CUBRID's Unknown class: pycubrid reports syntax errors, <name> is not a class and some permission errors with the same -493 / SQLSTATE 42S02 (#454), so a generic -493 still propagates (or, in the DDL fallbacks, keeps the logged empty result). get_view_definition() on a table (for which SHOW CREATE VIEW returns no row) now raises NoSuchTableError instead of returning "", as SQLAlchemy's suite expects. The 9 entries of the #530 group, including test_get_multi_unique_constraints[True-ObjectKind.ANY-ObjectScope.ANY…] that also needed #529, are removed from test/known_failures.txt for all three lanes; docs/FEATURE_SUPPORT.md (+ Korean) documents the behavior, and the per-lane counts in the test/known_failures.txt header and docs/SUPPORT_MATRIX.md (+ Korean) are updated (cubrid@sa2.0 68 / 61, pycubrid@sa2.0 54, pycubrid@sa2.1 58 / 51).
  • UnicodeText creates a CUBRID STRING column instead of the non-existent TEXT (#534) — CubridTypeCompiler overrode visit_text (Text → STRING) but not visit_unicode_text or visit_TEXT, so UnicodeText and sqlalchemy.TEXT fell through to SQLAlchemy's generic TEXT, and CREATE TABLE failed with "dba.TEXT is not defined" (-494) on every CUBRID version. Both now compile to STRING, like Text; CUBRID strings use the database charset, so there is no separate national text type. Alembic's compare_type now also treats UnicodeText like Text against a reflected VARCHAR(1073741823), so autogenerate does not report a spurious type change for it. New compiler tests and a live CREATE TABLE + Korean/Japanese/Chinese/emoji round trip on CUBRIDdb and pycubrid cover it, and the six UnicodeTextTest compliance entries are removed from test/known_failures.txt for every lane. docs/TYPES.md (+ Korean) and docs/PRD.md now list UnicodeText as STRING, and Unicode(n) as the VARCHAR(n) it actually compiles to; docs/TYPES.md (+ Korean) also notes that no column-level CHARSET/COLLATE is rendered for string and text types (collation= is dropped) and that non-ASCII text needs a UTF-8 database. The per-lane known-failure counts in the test/known_failures.txt header and docs/SUPPORT_MATRIX.md (+ Korean) are updated (cubrid@sa2.0 77 / 70, pycubrid@sa2.0 63, pycubrid@sa2.1 67 / 60).
  • Unique constraints are no longer reported twice, and views no longer reflect their base table's indexes (#529) — CUBRID implements a UNIQUE constraint as a unique index and cannot tell it apart from CREATE UNIQUE INDEX (same _db_index flags; SHOW CREATE TABLE prints both as UNIQUE KEY), so get_indexes() and get_unique_constraints() both listed the same object, and Table reflection built both a unique Index and a UniqueConstraint for it. Following SQLAlchemy's MySQL dialect, every get_unique_constraints() entry (catalog and SHOW CREATE TABLE paths) now carries "duplicates_index": <name>, so Table reflection and Alembic autogenerate keep only the unique index, and requirements.py opens unique_constraints_reflect_as_index and unique_index_reflect_as_unique_constraints. get_indexes() on a view returned the base table's indexes, including its primary key, because SHOW INDEXES IN <view> lists them; it now checks db_class.class_type and returns [] for a view (VCLASS). That lookup matches the name as given or folded to lower case, prefers the current user's class over a same-named class of another owner (CUBRID 11.2+ allows one per owner; a DBA view y_dup granted to PUBLIC made user u2's get_indexes("y_dup") return []), and is cached per Inspector. Reflection also no longer leaks server query entries (#548): the single-row lookups (db_class class type and table comment, SHOW CREATE TABLE for foreign keys and the unique-constraint fallback, SHOW CREATE VIEW) read their row with Result.first(), which closes the result, instead of fetchone(), which left it open. Each open result held one of the connection's 100 server query entries, so MetaData.reflect() of about 50–60 tables on one pycubrid connection failed with -830 "Cannot allocate query entry". An audit found no other unclosed single-row read in dialect.py, base.py, pycubrid_dialect.py, alembic_impl.py or trace.py (the raw-cursor fetchone() calls close their cursor in finally). New live tests reflect 150 tables on one connection (pycubrid on CUBRID 10.2 and 11.4, and CUBRIDdb) and reflect a u2 table that shares its name with a DBA view (CUBRID 11.2+). The 36 ComponentReflectionTest entries of the #529 group are removed from test/known_failures.txt for all three lanes; docs/FEATURE_SUPPORT.md (+ Korean) documents the behavior, and the per-lane counts in the test/known_failures.txt header and docs/SUPPORT_MATRIX.md (+ Korean) are updated (cubrid@sa2.0 83 / 76, pycubrid@sa2.0 69, pycubrid@sa2.1 73 / 66).
  • get_foreign_keys() returns foreign keys sorted by name (#531) — the constraints were returned in the order SHOW CREATE TABLE lists them, which is not name order, so the SQLAlchemy compliance suite's ComponentReflectionTest::test_get_multi_foreign_keys (which expects fk_dingalings_id_user before zz_email_add_id_fg) compared the wrong foreign keys even though each reflected constraint was correct. get_foreign_keys() (and therefore get_multi_foreign_keys()) now sorts by constraint name, like SQLAlchemy's built-in dialects. The 8 test_get_multi_foreign_keys[False-…] entries are removed from test/known_failures.txt for all three lanes; the per-lane counts in its header and in docs/SUPPORT_MATRIX.md (+ Korean) are updated (cubrid@sa2.0 119 / 112, pycubrid@sa2.0 105, pycubrid@sa2.1 109 / 102).
  • Foreign keys and unique constraints on column names containing parentheses are reflected (#532) — the SHOW CREATE TABLE parser matched a constraint's column list up to the first ), so a bracketed name such as [(3)] cut the list short: REFERENCES [dba.p] ([(3)]) reflected referred_columns: [], and autoloading the child table raised ArgumentError. _RE_FOREIGN_KEY and _RE_UNIQUE_KEY now match the column list as a sequence of bracketed names (each optionally followed by ASC/DESC, with optional whitespace inside the parentheses), so names containing (, ), , or spaces parse correctly. New offline regex tests use DDL captured from CUBRID 11.4 for such names. The 6 BizarroCharacterTest::test_fk_ref[…-(3)-…] entries are removed from test/known_failures.txt for all three lanes; the per-lane counts in its header and in docs/SUPPORT_MATRIX.md (+ Korean) are updated (cubrid@sa2.0 127 / 120, pycubrid@sa2.0 113, pycubrid@sa2.1 117 / 110).
  • Index.drop() and Alembic op.drop_index() emit DROP INDEX <name> ON <table> (#533) — CubridDDLCompiler had no visit_drop_index, so SQLAlchemy's generic DROP INDEX <name> was emitted and CUBRID rejected it with a -493 syntax error (CUBRID scopes an index to its table). The compiler now emits DROP INDEX <quoted name> ON <quoted table>, including the table's schema when one is set. CUBRID 10.2, 11.0, 11.2 and 11.4 have no DROP INDEX IF EXISTS (all four answer a syntax error), so DropIndex(..., if_exists=True) and op.drop_index(..., if_exists=True) now raise CompileError instead of emitting SQL the server rejects; check inspect(conn).has_index() first, as Index.drop(checkfirst=True) does. An index not bound to a table also raises CompileError, and op.drop_index() without table_name (which Alembic binds to a placeholder table named no_table) raises CompileError asking for table_name instead of emitting DROP INDEX <name> ON no_table. CubridDialect.has_index() is now @reflection.cached like has_table() and the get_* methods, so an Inspector answers has_index() from its cache until clear_cache() (calls without an info_cache, such as Index.drop(checkfirst=True), still query the catalog each time); with the drop fixed this was the remaining HasIndexTest::test_has_index[inspector] failure. Because a cached answer lives as long as the Inspector, a failing catalog query in has_index() now raises instead of being swallowed as False; a missing table or index still returns False. The per-lane known-failure counts in the test/known_failures.txt header and docs/SUPPORT_MATRIX.md (+ Korean) are updated (cubrid@sa2.0 133 / 126, pycubrid@sa2.0 119, pycubrid@sa2.1 123 / 116). New compiler tests, an offline cache test, offline Alembic --sql tests and live Index.drop() / op.drop_index() tests cover it, and the HasIndexTest::test_has_index[dialect|inspector] compliance entries are removed from test/known_failures.txt for every lane. docs/ALEMBIC.md (+ Korean) documents the ON <table> form and the if_exists limitation.
  • JSON .as_numeric(p, s) returns Decimal instead of float (#535) — a JSON element cast with as_numeric() was rendered CAST(JSON_EXTRACT(...) AS DOUBLE), so the driver returned a float, and because the dialect declares native decimal support no result processor converted it: select(t.c.data["a"].as_numeric(10, 2)) returned 15.0 instead of Decimal('15.00') and lost precision, on SQLAlchemy 2.0 and 2.1 alike. A Numeric target with both precision and scale now renders CAST(JSON_EXTRACT(...) AS NUMERIC(p,s)), as SQLAlchemy's MySQL dialect renders DECIMAL(p, s), and both drivers return a Decimal at scale s (JSON numbers, including exponent notation such as 1e3, plain-decimal numeric strings such as "15.5", and true/false cast; JSON null is still SQL NULL). CUBRID raises -181 ("Cannot coerce value of domain json to domain numeric") for a string in exponent notation ("1e3") and for a non-numeric string such as "abc" or "", and -427 (data overflow) for a value that does not fit NUMERIC(p,s). For exponent-notation strings and values outside NUMERIC(p,s), use as_float(), which casts to DOUBLE. as_float() also raises -181 for a non-numeric string, so validate or filter such values before casting; with asdecimal=False the result is still converted to float. as_float() and any Float stay DOUBLE, and a Numeric without both precision and scale also stays DOUBLE, because a bare CUBRID NUMERIC means NUMERIC(15,0) and would truncate. Verified on CUBRID 10.2 and 11.4. New offline compiler tests and a live round trip in test_integration.py (CUBRIDdb and pycubrid, SQLAlchemy 2.0.53 and 2.1.1) cover it; the 18 JSONTest::test_index_cross_casts[...-numeric] entries are removed from test/known_failures.txt (lane pycubrid@sa2.1: 143 / 136 → 125 / 118), and docs/FEATURE_SUPPORT.md (+ Korean) lists as_numeric().
  • isolation_level="AUTOCOMMIT" works on every driver, and an engine-level isolation level survives a per-connection override (#501) — docs/TROUBLESHOOTING.md recommended execution_options(isolation_level="AUTOCOMMIT"), but get_isolation_level_values() did not list it, so execution_options() raised ArgumentError and create_engine(..., isolation_level="AUTOCOMMIT") raised ValueError on first connect, on cubrid://, cubrid+pycubrid:// and cubrid+aiopycubrid://. CubridDialect kept isolation_level to itself and applied it from its own on_connect hooks, bypassing SQLAlchemy's validation, and its reset_isolation_level() override always reset a checked-in connection to READ COMMITTED, dropping an engine-level level after a per-connection override. Following SQLAlchemy's mysqldb/psycopg pattern, AUTOCOMMIT is now a valid level: set_isolation_level() turns on the driver's autocommit (CUBRIDdb, pycubrid and the async adapter all expose the property), and any other level turns it off before SET TRANSACTION ISOLATION LEVEL. It toggles only when the mode changes, because on pycubrid each toggle ends the transaction and triggers a reconnect. detect_autocommit_setting() reads the same flag, so create_engine(skip_autocommit_rollback=True) works too. isolation_level is passed to DefaultDialect, which applies it on connect after the dialect disables autocommit and restores it on checkin; the reset_isolation_level() override and the on_connect isolation handling are removed. An engine-level alias is stored under its canonical name (for example CURSOR STABILITY becomes READ COMMITTED), so SQLAlchemy's checkin restore accepts it. Invalid levels now raise ArgumentError from create_engine(), engine.execution_options() and conn.execution_options() on every driver, and a non-string isolation_level raises it from the dialect constructor; calling dialect.set_isolation_level() directly still raises ValueError, and a DBAPI connection without an autocommit property now fails with AttributeError instead of being treated as non-autocommit. Behavior changes for code outside create_engine(): on_connect() no longer applies isolation_level, so a pool built by hand around dialect.on_connect() must apply it itself (or use dialect._builtin_onconnect() like create_engine()); and dialect.isolation_level now holds the canonical name (CURSOR STABILITY reads back as READ COMMITTED). On pycubrid, AUTOCOMMIT statements run at the server default level and reconnect the broker session each time (cubrid-lab/pycubrid#468); the docs recommend skip_autocommit_rollback=True with an engine-level AUTOCOMMIT there. New live tests on all three drivers cover each path: an AUTOCOMMIT row is visible to a second session before any commit and survives rollback(), the same pooled connection is transactional after checkin, the engine-level level (SERIALIZABLE or AUTOCOMMIT, sync and async) and an engine-level alias are restored on the same pooled connection after a per-connection override, skip_autocommit_rollback=True works with engine-level AUTOCOMMIT, and invalid levels raise ArgumentError. They fail without the fix. docs/ISOLATION_LEVELS.md, docs/TROUBLESHOOTING.md and docs/FEATURE_SUPPORT.md (+ Korean) document AUTOCOMMIT and the checkin restore.
  • Engine- and connection-level isolation_level no longer falls back to READ COMMITTED on pycubrid after commit() / rollback() (#505) — after the driver's commit() or rollback() the broker returns the CAS status byte as inactive (out of transaction), and pycubrid 1.7.1 (and main) then opens a new broker connection before the next request. The new session starts at the server default level, and pycubrid restores only autocommit, so create_engine("cubrid+pycubrid://…", isolation_level="SERIALIZABLE") already reported READ COMMITTED on the first checkout (SQLAlchemy rolls back after its first-connect checks), and a per-connection level was lost at the next commit. CUBRIDdb keeps the same session and was not affected; the driver bug is cubrid-lab/pycubrid#468. PyCubridDialect (and the async dialect) now remember the level they set on each DBAPI connection and re-apply it in do_commit() / do_rollback(). This costs one SET TRANSACTION ISOLATION LEVEL + COMMIT per commit/rollback, only on connections with a configured level; nothing runs per statement, and such a pooled connection holds a broker CAS while idle. A failed re-apply never turns a successful commit into an error or masks the exception that caused a rollback: it is logged and retried in do_begin() before the next transaction's first statement. On an engine without a configured level, checkin after a per-connection override stops the re-apply for that connection. Remove the workaround once a pycubrid release fixing pycubrid#468 is the minimum. New live tests on all three drivers check get_isolation_level() after commit, rollback, pool checkin (including the pool's reset-on-return rollback alone), engine.begin() and a Session, and fail without the fix on pycubrid; pycubrid-only live tests simulate a failed re-apply and check that the commit stands, the rollback's IntegrityError propagates and the next transaction runs at the configured level. docs/DRIVER_COMPAT.md Known Issue 10 and docs/ISOLATION_LEVELS.md (+ Korean) document the driver behavior and the re-apply. Resetting to the engine level after a per-connection override is tracked in #501.
  • driver_connection on cubrid+aiopycubrid:// is now the pycubrid.aio connection (#520) — PyCubridAsyncDialect did not override get_driver_connection(), so (await conn.get_raw_connection()).driver_connection returned SQLAlchemy's AsyncAdapt_pycubrid_connection adapter instead of the driver's own connection. The dialect now returns the wrapped pycubrid.aio.AsyncConnection, as SQLAlchemy's asyncpg and aiomysql dialects do, so driver-specific async APIs (ping(), cursor(), ...) are reachable through the documented accessor. A unit test and a live async test cover it, and the async integration tests no longer reach the driver connection through .dbapi_connection.driver_connection or the adapter's _connection. Code that relied on driver_connection being the adapter should use dbapi_connection instead.
  • The SQLAlchemy compliance suite no longer silently skips most of its tests (#463) — every property in sqlalchemy_cubrid/requirements.py returned one shared exclusions.open() / exclusions.closed() object, and SQLAlchemy extends the first requirement of a stacked @testing.requires chain in place, so after collection every "open" requirement also carried the closed rule's skip. Each property now returns a new object (a regression test checks that stacking does not leak). About 300 more suite tests now run per lane (pycubrid, SQLAlchemy 2.0.53, CUBRID 11.4: 334 → 631 passed), and the baselines of all three lanes were recaptured on fresh CUBRID 10.2 and 11.4 databases and classified. The recapture also opens reflects_pk_names and implicitly_named_constraints (the suite reported unexpected successes), gates unicode_ddl on a UTF-8 database (the official Docker image creates ISO-8859-1 databases, where non-ASCII table names break both drivers' catalog decoding for every later test), and closes precision_generic_float_type (CUBRID FLOAT is single precision). Newly visible dialect bugs are listed as strict known failures, each linked to its follow-up issue: unique constraints reported twice and views reflecting their base table's indexes (#529), missing-object reflection not raising NoSuchTableError (#530), foreign keys returned in DDL order instead of sorted by name (#531), bracketed column names containing parentheses such as [(3)] not parsed in FK/UNIQUE DDL (#532), Index.drop() emitting DROP INDEX without ON <table> (#533), UnicodeText compiling to TEXT (#534), and JSON .as_numeric() returning float instead of Decimal (#535).
  • Inspector.has_table() honors the inspector cache (#463) — CubridDialect.has_table() was not decorated with @reflection.cache, so inspect(engine).has_table() queried the catalog on every call and reported a table created after the first call before clear_cache(), unlike SQLAlchemy's built-in dialects. Found by the SQLAlchemy 2.1 HasTableTest::test_has_table_cache in the new pycubrid compliance lane (SQLAlchemy 2.0's harness never reached it). Direct dialect.has_table() calls without an info_cache and create_all(checkfirst=True) are unaffected.
  • cubrid:// executemany no longer stores the previous row's value for None or reports a last-row rowcount (#502) — CUBRIDdb 11.3.0.51 skips binding None and prepares executemany once, so a None parameter kept the previous row's value, and its rowcount counted only the last row. Through SQLAlchemy this silently corrupted text() and Core UPDATE/DELETE executemany and made batched ORM UPDATEs raise StaleDataError. CubridDialect.do_executemany now executes each parameter set and reports the summed rowcount, so supports_sane_multi_rowcount is accurate on both drivers. cubrid+pycubrid:// and cubrid+aiopycubrid:// keep the driver's prepare-once executemany, and a multi-row Core insert() that uses insertmanyvalues is unaffected. An INSERT into a table with a column whose type defines bind_expression() falls back to executemany (#421), so on cubrid:// it uses the per-row guard. The textual executemany differential case now includes NULL, the CUBRIDdb RowCountTest multi-row entries are removed from test/known_failures.txt, docs/DRIVER_COMPAT.md (+ Korean) records the driver bug and when the guard will be removed, and docs/SUPPORT_MATRIX.md (+ Korean) documents executemany rowcount behavior and the guard's per-row cost.
  • Alembic finds CubridImpl with a default env.py (#504) — alembic upgrade against a cubrid://, cubrid+pycubrid:// or cubrid+aiopycubrid:// URL failed with KeyError: 'cubrid' unless env.py imported sqlalchemy_cubrid.alembic_impl by hand. The package declared an alembic.ddl entry point, but no Alembic release reads that group: Alembic looks the impl up in a registry keyed by dialect.name that DefaultImpl subclasses fill on import, and the alembic.plugins entry points it does read exist only from 1.18 onward. sqlalchemy_cubrid/dialect.py now imports alembic_impl when Alembic is installed and skips it when it is not, which is how other third-party dialects register their impl and works on every supported Alembic release; all dialect variants share name = "cubrid". The dead alembic.ddl entry point is removed. The standard alembic init template works unchanged for the synchronous URLs; for cubrid+aiopycubrid:// online migrations, docs/ALEMBIC.md now points to Alembic's async template (alembic init -t async), whose unmodified env.py was verified live on CUBRID 11.4 (the standard template's synchronous engine fails there with MissingGreenlet). The import never stops the dialect from loading: a missing Alembic is skipped silently, and an installed Alembic that fails to import (1.7.0/1.7.1 raise NameError on SQLAlchemy 2.x) only disables the integration with one RuntimeWarning naming the exception (logged via the sqlalchemy_cubrid.dialect logger instead if a warning filter turns it into an error). The [alembic]/[dev] extras, the pre-commit mypy hook and the docs now require alembic>=1.7.2,<2.0, the oldest release that imports on SQLAlchemy 2.x. A new test/test_alembic_registration.py runs the alembic CLI in a fresh interpreter on an unmodified alembic init project, offline (--sql) for all four URL forms and online against a live CUBRID; it fails on the previous code with the reported KeyError. The roundtrip tests no longer import alembic_impl themselves. README (and translations), docs/ALEMBIC.md, docs/TROUBLESHOOTING.md, docs/SUPPORT_MATRIX.md, docs/ARCHITECTURE.md (+ Korean), docs/PRD.md, docs/FEATURE_SUPPORT.md, AGENTS.md and the wheel smoke checks now describe and verify registration on dialect import.
  • Async LargeBinary / BLOB binding no longer raises AttributeError (#500) — AsyncAdapt_pycubrid_dbapi copied only paramstyle, the exception classes and the type objects from pycubrid, but SQLAlchemy's LargeBinary bind processor reads dialect.dbapi.Binary whenever the statement compiles, so every cubrid+aiopycubrid:// INSERT/UPDATE touching a binary column failed, even with None or an unset ORM attribute. The adapter now copies the full PEP 249 module surface (apilevel, threadsafety, paramstyle, all exception classes, Date/Time/Timestamp, the *FromTicks constructors, Binary and the STRING/BINARY/NUMBER/DATETIME/ROWID type objects) from the sync module via one declared tuple. A unit test asserts every PEP 249 name on pycubrid is on the adapter; live async tests cover Core inserts of None and bytes into LargeBinary/BLOB and an ORM insert with an unset LargeBinary. The async binding xfails from #485 are removed, leaving only the strict LOB-read locator xfails (cubrid-lab/pycubrid#441); docs/DRIVER_COMPAT.md and docs/TYPES.md (+ Korean) no longer list the async Binary issue.
  • String(0) / VARCHAR(0) / NVARCHAR(0) no longer silently compile to the 4096 default (#440) — the type compiler checked the length by truthiness, so an explicit length=0 was treated as "no length" and rendered VARCHAR(4096) (or NCHAR VARYING(4096)). length=None still gets the documented default; an explicit zero now raises CompileError, since VARCHAR(0) is not a valid CUBRID length.
  • Reflection now returns table and view names in deterministic order (#443) — added ORDER BY class_name to get_table_names() and get_view_names() catalog queries so reflection results no longer depend on database row order.
  • Contributor workflow and docs exceptions clarified — normal contributions use shared checks and matching docs; maintainers coordinate project-specific reviews, labels, translation deferrals and releases. Docs-not-needed body reasons require a populated standalone statement, with quoted/template examples excluded; executable doctests and real event/workflow regressions run locally and in docs-sync. Four existing reusable workflow callers are pinned to a verified upstream commit without changing their inputs or rollout settings.
  • Pure-driver integration setup is explicit (#464) — tox -e integration now requires a cubrid+pycubrid:// test URL rather than the former bare cubrid:// C-extension URL. A bounded sync/async SELECT 1 preflight fails before pytest when the URL, driver or database is unavailable. Async and stress suites derive their endpoint from the selected URL, preserving authentication, port and query options; an explicit CUBRID_TEST_AURL remains supported. The formal C-extension compliance CI route is unchanged, and optional native-driver comparisons remain explicitly skipped when that driver is absent.
  • Local tooling agrees with CI (#464) — Ruff/mypy pre-commit revisions match the dev pins; the isolated mypy hook uses interpreter-compatible SQLAlchemy pins and existing Alembic dependencies without automatic stub installation. Tox covers Python 3.10–3.14, uses integration markers, retains 95% coverage, and matches CI's type-check cells. CLI/CI/tox share the Makefile's maintained Python source paths while hooks keep all tracked Python/pyi coverage. A deterministic consistency check rejects stale pins, missing interpreter branches, source omissions and runner drift. Two helper scripts received formatting-only changes with unchanged ASTs.
  • Strict typing is enforced across SQLAlchemy 2.0 and 2.1 (#459) — timezone constructor annotations retain their always-enabled timezone behavior; compiler overrides match upstream positional arguments with typed boundaries for unannotated framework hooks. UNIQUE-index FK collision checks now raise a clear CompileError when no live Alembic connection is available. make typecheck reports dependency versions, and two pinned CI cells must pass through the required matrix-result check. Runtime SQL compilation and the 95% offline coverage threshold remain unchanged.
  • Async installation includes SQLAlchemy's required bridge (#448) — the existing [pycubrid] and [dev] extras now request SQLAlchemy[asyncio], which installs greenlet on both SQLAlchemy 2.0 and 2.1. The [pycubrid] extra intentionally supports sync and async URLs; bare installation keeps the same dependency contract. Fresh wheel smokes exercise async engine creation on 2.0.53 and 2.1.1 without a database or ambient packages. This is a patch-level installation bug fix.
  • Installation guidance distinguishes the driver from the async bridge (#448) — FAQs, connection and troubleshooting guides, and translations now consistently state that pycubrid itself needs no CUBRID native libraries, while the [pycubrid] extra's greenlet dependency may need build tools when no compatible wheel is available.
  • Last-insert-ID SQL fallback works on SQLAlchemy 2.x (#458) — both execution contexts now obtain a regular cursor from the active DBAPI connection for SELECT LAST_INSERT_ID(), instead of calling SQLAlchemy's unimplemented server-side-cursor hook. Native driver IDs (including None) remain preferred, and the temporary cursor is closed even if execution, fetching, or integer conversion fails. This is a backward-compatible bug fix suitable for a patch release.
  • Nightly mutation testing migrated to mutmut 3 — the dev extra now pins mutmut>=3.8,<4 (was >=2.5,<3; supersedes #450). The nightly mutation-testing job used mutmut 2 CLI options (run --paths-to-mutate, result-ids) that mutmut 3 removed, and masked failures with || true, so an upgrade would have printed a wrong score instead of failing. The [mutmut] section in setup.cfg now uses mutmut 3 keys (source_paths, only_mutate, pytest arguments, forkserver process isolation because two MERGE tests cannot run twice in one process), and the job reads the score from mutmut export-cicd-stats. A mutmut crash, failing clean test run, zero mutants or zero kills now fails the step; surviving mutants only lower the reported score, and the job remains non-gating (continue-on-error). mutmut 3 generates more mutants than mutmut 2, so scores are not comparable with the earlier 288/449 baseline. CI/dev tooling only; no runtime change.

Docs

  • Added a README "First contribution" guide (with Korean translation) pointing newcomers to the right sibling repo for their first PR, and documented the good first issue → status: in progress label lifecycle in AGENTS.md.