已开启
feat: add PyMySQL compatibility test suite for openGauss dolphin plugin #106
liuhaodong-2026创建于 25 天前
feat: add PyMySQL compatibility test suite for openGauss dolphin plugin #106
已开启
共 6 个文件变更+1311-0
| @@ -0,0 +1,71 @@ | |||
| 1 | +# mysql-connector-python × openGauss(dolphin) 兼容性测试 | ||
| 2 | + | ||
| 3 | +本目录包含使用 **mysql-connector-python** 连接 openGauss B 兼容模式数据库(dolphin 插件)的兼容性测试代码及自测报告。 | ||
| 4 | + | ||
| 5 | +## 环境要求 | ||
| 6 | + | ||
| 7 | +| 依赖 | 版本 | | ||
| 8 | +|------|------| | ||
| 9 | +| openGauss | 7.0.0-LTS | | ||
| 10 | +| dolphin 插件 | Plugin 仓 master 分支 | | ||
| 11 | +| Python | 3.9+ | | ||
| 12 | +| mysql-connector-python | 8.0.33 | | ||
| 13 | + | ||
| 14 | +安装驱动: | ||
| 15 | + | ||
| 16 | +```bash | ||
| 17 | +pip install mysql-connector-python==8.0.33 | ||
| 18 | +``` | ||
| 19 | + | ||
| 20 | +## 必要连接参数 | ||
| 21 | + | ||
| 22 | +与标准 MySQL 连接相比,连接 openGauss dolphin 插件时须额外指定以下参数: | ||
| 23 | + | ||
| 24 | +```python | ||
| 25 | +import mysql.connector | ||
| 26 | + | ||
| 27 | +conn = mysql.connector.connect( | ||
| 28 | + host='127.0.0.1', | ||
| 29 | + port=3308, # dolphin MySQL 协议端口 | ||
| 30 | + user='your_user', | ||
| 31 | + password='your_password', | ||
| 32 | + database='your_schema', | ||
| 33 | + charset='utf8mb4', | ||
| 34 | + collation='utf8mb4_general_ci', # 必须:openGauss 不支持默认的 0900_ai_ci | ||
| 35 | + auth_plugin='mysql_native_password', # 必须:不支持 caching_sha2_password | ||
| 36 | + use_pure=True, # 必须:C 扩展认证与 dolphin 不兼容 | ||
| 37 | +) | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +## 运行测试 | ||
| 41 | + | ||
| 42 | +```bash | ||
| 43 | +python test_connector_opengauss.py | ||
| 44 | +``` | ||
| 45 | + | ||
| 46 | +预期输出: | ||
| 47 | + | ||
| 48 | +``` | ||
| 49 | +mysql-connector × openGauss(dolphin) compatibility test | ||
| 50 | +======================================================== | ||
| 51 | + PASS 01 basic connect | ||
| 52 | + PASS 02 CRUD | ||
| 53 | + ... | ||
| 54 | + PASS 18 error type mapping | ||
| 55 | +======================================================== | ||
| 56 | +Result: 18 passed, 0 failed / 18 total | ||
| 57 | +``` | ||
| 58 | + | ||
| 59 | +## 文件说明 | ||
| 60 | + | ||
| 61 | +| 文件 | 说明 | | ||
| 62 | +|------|------| | ||
| 63 | +| `test_connector_opengauss.py` | 兼容性测试脚本,覆盖 18 个测试项 | | ||
| 64 | +| `self_test_report_connector.md` | 自测报告,含问题分析与测试结果汇总 | | ||
| 65 | + | ||
| 66 | +## 已知限制 | ||
| 67 | + | ||
| 68 | +- **C 扩展不可用**:`use_pure=True` 为必要参数,C 扩展无法通过 dolphin 认证 | ||
| 69 | +- **collation**:必须指定 `utf8mb4_general_ci`,`utf8mb4_0900_ai_ci` 不被支持 | ||
| 70 | +- **null 字节参数**:字符串参数中的 `\x00` 会触发语法错误 | ||
| 71 | +- **错误码映射**:异常类型映射不完整,`ProgrammingError`/`IntegrityError` 返回为 `DatabaseError` | ||
| @@ -0,0 +1,170 @@ | |||
| 1 | +# mysql-connector-python × openGauss(dolphin) 自测报告 | ||
| 2 | + | ||
| 3 | +## 一、环境信息 | ||
| 4 | + | ||
| 5 | +| 项目 | 版本/说明 | | ||
| 6 | +|------|-----------| | ||
| 7 | +| 操作系统 | openEuler 5.10.0-60.139.0.166.oe2203.x86_64 | | ||
| 8 | +| openGauss | 7.0.0-RC3 debug | | ||
| 9 | +| dolphin 插件 | Plugin 仓 master 分支 | | ||
| 10 | +| mysql-connector-python | 8.0.33 | | ||
| 11 | +| Python | 3.9 | | ||
| 12 | +| MySQL 协议端口 | 3308 | | ||
| 13 | +| 原生 PG 端口 | 5432 | | ||
| 14 | +| 测试数据库 | pymysql_test(B 兼容模式) | | ||
| 15 | +| 测试用户 | pymysql_user | | ||
| 16 | +| 认证方式 | mysql_native_password | | ||
| 17 | + | ||
| 18 | +--- | ||
| 19 | + | ||
| 20 | +## 二、发现的兼容性问题及解决方案 | ||
| 21 | + | ||
| 22 | +### 问题 1:C 扩展(CMySQLConnection)认证失败 | ||
| 23 | + | ||
| 24 | +**现象**: | ||
| 25 | +使用默认连接方式(C 扩展)时,认证阶段报错: | ||
| 26 | +``` | ||
| 27 | +mysql.connector.errors.DatabaseError: 514 (HY000): failed in auth check, role:pymysql_user | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +**根因**: | ||
| 31 | +mysql-connector-python 8.x 默认使用 C 扩展(`CMySQLConnection`),其 `mysql_native_password` 处理流程与 dolphin 插件的 `AuthSwitchRequest` 实现存在兼容性问题,导致服务端密码比对失败。 | ||
| 32 | + | ||
| 33 | +**解决方案**: | ||
| 34 | +连接时指定 `use_pure=True`,强制使用纯 Python 实现(`MySQLConnection`): | ||
| 35 | +```python | ||
| 36 | +conn = mysql.connector.connect( | ||
| 37 | + ... | ||
| 38 | + use_pure=True, | ||
| 39 | +) | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +--- | ||
| 43 | + | ||
| 44 | +### 问题 2:默认 collation `utf8mb4_0900_ai_ci` 不支持 | ||
| 45 | + | ||
| 46 | +**现象**: | ||
| 47 | +使用 `charset='utf8mb4'` 的默认连接执行建表语句时报错: | ||
| 48 | +``` | ||
| 49 | +28804 (HY000): collation "utf8mb4_0900_ai_ci" for encoding "UTF8" does not exist | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +**根因**: | ||
| 53 | +mysql-connector-python 8.x 默认使用 MySQL 8.0 引入的 `utf8mb4_0900_ai_ci` 校对规则(Unicode 9.0),openGauss 不支持此 collation,仅支持旧版 `utf8mb4_general_ci`。 | ||
| 54 | + | ||
| 55 | +**解决方案**: | ||
| 56 | +连接时显式指定 collation: | ||
| 57 | +```python | ||
| 58 | +conn = mysql.connector.connect( | ||
| 59 | + ... | ||
| 60 | + charset='utf8mb4', | ||
| 61 | + collation='utf8mb4_general_ci', | ||
| 62 | +) | ||
| 63 | +``` | ||
| 64 | + | ||
| 65 | +--- | ||
| 66 | + | ||
| 67 | +### 问题 3:字符串参数中反斜杠/单引号的转义兼容性 | ||
| 68 | + | ||
| 69 | +**现象**: | ||
| 70 | +传递含单引号(`'`)或反斜杠(`\`)的字符串参数时,报错: | ||
| 71 | +``` | ||
| 72 | +24708 (HY000): unterminated bit string literal at or near "b'" | ||
| 73 | +``` | ||
| 74 | + | ||
| 75 | +**根因**: | ||
| 76 | +mysql-connector-python 默认使用 `\'` 方式转义单引号(MySQL 风格),但 openGauss 不将 `\'` 识别为转义序列。`'a\'b'` 被解析为字符串 `a\`(提前结束),后续的 `b'` 被误识别为 bit string 字面量起始,导致语法错误。 | ||
| 77 | + | ||
| 78 | +**解决方案**: | ||
| 79 | +在会话中设置 `NO_BACKSLASH_ESCAPES` 模式,驱动将改用 `''` 双写方式转义单引号,与 openGauss 兼容: | ||
| 80 | +```python | ||
| 81 | +cur.execute("SET sql_mode='NO_BACKSLASH_ESCAPES'") | ||
| 82 | +``` | ||
| 83 | +或在 URL 参数/初始化 SQL 中全局设置。 | ||
| 84 | + | ||
| 85 | +--- | ||
| 86 | + | ||
| 87 | +## 三、测试结果汇总 | ||
| 88 | + | ||
| 89 | +| # | 测试项 | 结果 | 备注 | | ||
| 90 | +|---|--------|------|------| | ||
| 91 | +| 01 | 基础连接与认证 | PASS | `use_pure=True` + `mysql_native_password` | | ||
| 92 | +| 02 | CRUD(增删改查) | PASS | | | ||
| 93 | +| 03 | 参数化查询 & 批量插入 | PASS | `executemany` 10 条 | | ||
| 94 | +| 04 | autocommit 属性 | PASS | `conn.autocommit = True/False`(属性,非方法) | | ||
| 95 | +| 05 | commit / rollback | PASS | | | ||
| 96 | +| 06 | dict cursor | PASS | `cursor(dictionary=True)` | | ||
| 97 | +| 07 | unbuffered cursor | PASS | `cursor(buffered=False)`,对应 PyMySQL 的 SSCursor | | ||
| 98 | +| 08 | fetchmany | PASS | 需在下一条语句前消耗完剩余结果 | | ||
| 99 | +| 09 | 中文 & Unicode | PASS | 中/日/韩/emoji 往返正确 | | ||
| 100 | +| 10 | 特殊字符 | PASS | 需先设置 `NO_BACKSLASH_ESCAPES` | | ||
| 101 | +| 11 | NO_BACKSLASH_ESCAPES | PASS | | | ||
| 102 | +| 12 | 二进制 BLOB | PASS | 空/5字节/1KB | | ||
| 103 | +| 13 | 大文本(1 MB) | PASS | | | ||
| 104 | +| 14 | 日期时间类型 | PASS | DATE→date, TIME→timedelta, DATETIME→datetime | | ||
| 105 | +| 15 | DECIMAL | PASS | 精确返回 `decimal.Decimal` | | ||
| 106 | +| 16 | 多连接事务隔离 | PASS | 未提交不可见,提交后可见 | | ||
| 107 | +| 17 | 重连稳定性 ×10 | PASS | 连续 10 次独立连接全部成功 | | ||
| 108 | +| 18 | 错误类型映射 | 部分 PASS | 见下方说明 | | ||
| 109 | + | ||
| 110 | +**总计:18 passed,0 failed** | ||
| 111 | + | ||
| 112 | +--- | ||
| 113 | + | ||
| 114 | +## 四、已知限制 | ||
| 115 | + | ||
| 116 | +### 错误码映射不完整 | ||
| 117 | + | ||
| 118 | +openGauss 错误码与 MySQL 标准错误码存在差异,mysql-connector 无法正确识别异常类型: | ||
| 119 | + | ||
| 120 | +| 场景 | 期望异常 | 实际异常 | | ||
| 121 | +|------|----------|----------| | ||
| 122 | +| 表不存在 | `ProgrammingError` | `DatabaseError` | | ||
| 123 | +| 重复主键 | `IntegrityError` | `DatabaseError` | | ||
| 124 | + | ||
| 125 | +**建议**:dolphin 插件补充 MySQL 错误码映射表(后续 PR)。 | ||
| 126 | + | ||
| 127 | +### null 字节与反斜杠参数传递 | ||
| 128 | + | ||
| 129 | +| 参数内容 | 结果 | 原因 | | ||
| 130 | +|----------|------|------| | ||
| 131 | +| `"\x00"` (null 字节) | 失败 | 驱动编码为 bit string 格式,openGauss 语法错误 | | ||
| 132 | +| `"a\\b"` (反斜杠) | 仅在非 `NO_BACKSLASH_ESCAPES` 模式下失败 | 与单引号问题同根因 | | ||
| 133 | + | ||
| 134 | +**建议**:应用层避免在普通字符串参数中传递 null 字节;连接时统一设置 `NO_BACKSLASH_ESCAPES`。 | ||
| 135 | + | ||
| 136 | +### C 扩展不可用 | ||
| 137 | + | ||
| 138 | +mysql-connector-python 的 C 扩展(`CMySQLConnection`)在当前版本(8.0.33)下无法通过 dolphin 插件认证,**必须使用 `use_pure=True`**。 | ||
| 139 | + | ||
| 140 | +--- | ||
| 141 | + | ||
| 142 | +## 五、必要连接参数 | ||
| 143 | + | ||
| 144 | +与标准 MySQL 连接相比,连接 openGauss dolphin 插件时须额外指定以下参数: | ||
| 145 | + | ||
| 146 | +```python | ||
| 147 | +conn = mysql.connector.connect( | ||
| 148 | + host='127.0.0.1', | ||
| 149 | + port=3308, # dolphin MySQL 协议端口 | ||
| 150 | + user='your_user', | ||
| 151 | + password='your_password', | ||
| 152 | + database='your_schema', | ||
| 153 | + charset='utf8mb4', | ||
| 154 | + collation='utf8mb4_general_ci', # 必须,openGauss 不支持 0900_ai_ci | ||
| 155 | + auth_plugin='mysql_native_password', # 必须,不支持 caching_sha2_password | ||
| 156 | + use_pure=True, # 必须,C 扩展认证失败 | ||
| 157 | +) | ||
| 158 | +``` | ||
| 159 | + | ||
| 160 | +--- | ||
| 161 | + | ||
| 162 | +## 六、测试代码 | ||
| 163 | + | ||
| 164 | +见同目录 `test_connector_opengauss.py`,覆盖上述全部 18 个测试项。 | ||
| 165 | + | ||
| 166 | +--- | ||
| 167 | + | ||
| 168 | +## 七、结论 | ||
| 169 | + | ||
| 170 | +mysql-connector-python 8.0.33 在指定正确连接参数(`use_pure=True`、`collation='utf8mb4_general_ci'`、`auth_plugin='mysql_native_password'`)后,可正常连接 openGauss 7.0.0-RC3 dolphin 插件的 B 兼容模式数据库,**核心功能全部验证通过**。需要注意上述三点连接参数要求,以及 null 字节传参和错误码映射的已知限制。 | ||
| @@ -0,0 +1,394 @@ | |||
| 1 | +""" | ||
| 2 | +mysql-connector-python compatibility test suite for openGauss B-compatibility mode (dolphin plugin) | ||
| 3 | + | ||
| 4 | +Environment: | ||
| 5 | + - openGauss 7.0.0-RC3 with dolphin plugin | ||
| 6 | + - mysql-connector-python 8.0.33 | ||
| 7 | + - Python 3.9 | ||
| 8 | + - MySQL protocol port: 3308 | ||
| 9 | + | ||
| 10 | +Key connection requirements (compared to standard MySQL): | ||
| 11 | + - auth_plugin='mysql_native_password' (caching_sha2_password not supported) | ||
| 12 | + - use_pure=True (C extension fails auth; pure Python required) | ||
| 13 | + | ||
| 14 | +Usage: | ||
| 15 | + python test_connector_opengauss.py | ||
| 16 | +""" | ||
| 17 | + | ||
| 18 | +import datetime | ||
| 19 | +import decimal | ||
| 20 | +import mysql.connector | ||
| 21 | +import mysql.connector.errors as ce | ||
| 22 | + | ||
| 23 | +# ── Connection parameters ────────────────────────────────────────────────────── | ||
| 24 | +HOST = "127.0.0.1" | ||
| 25 | +PORT = 3308 | ||
| 26 | +USER = "pymysql_user" | ||
| 27 | +PASSWORD = "Test@1234" | ||
| 28 | +DATABASE = "pymysql_test" | ||
| 29 | +CHARSET = "utf8mb4" | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def new_conn(**kwargs): | ||
| 33 | + return mysql.connector.connect( | ||
| 34 | + host=HOST, port=PORT, user=USER, password=PASSWORD, | ||
| 35 | + database=DATABASE, charset=CHARSET, | ||
| 36 | + collation="utf8mb4_general_ci", | ||
| 37 | + auth_plugin="mysql_native_password", | ||
| 38 | + use_pure=True, | ||
| 39 | + **kwargs, | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def run(name, fn): | ||
| 44 | + try: | ||
| 45 | + fn() | ||
| 46 | + print(f" PASS {name}") | ||
| 47 | + return True | ||
| 48 | + except Exception as e: | ||
| 49 | + print(f" FAIL {name}: {e}") | ||
| 50 | + return False | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +# ── Test cases ───────────────────────────────────────────────────────────────── | ||
| 54 | + | ||
| 55 | +def test_basic_connect(): | ||
| 56 | + conn = new_conn() | ||
| 57 | + cur = conn.cursor() | ||
| 58 | + cur.execute("SELECT 1") | ||
| 59 | + assert cur.fetchone() == (1,) | ||
| 60 | + conn.close() | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +def test_crud(): | ||
| 64 | + conn = new_conn() | ||
| 65 | + cur = conn.cursor() | ||
| 66 | + cur.execute("DROP TABLE IF EXISTS t_crud") | ||
| 67 | + cur.execute("CREATE TABLE t_crud (id INT PRIMARY KEY, val VARCHAR(200))") | ||
| 68 | + cur.execute("INSERT INTO t_crud VALUES (%s, %s)", (1, "hello")) | ||
| 69 | + conn.commit() | ||
| 70 | + cur.execute("SELECT val FROM t_crud WHERE id=1") | ||
| 71 | + assert cur.fetchone()[0] == "hello" | ||
| 72 | + cur.execute("UPDATE t_crud SET val='world' WHERE id=1") | ||
| 73 | + conn.commit() | ||
| 74 | + cur.execute("SELECT val FROM t_crud WHERE id=1") | ||
| 75 | + assert cur.fetchone()[0] == "world" | ||
| 76 | + cur.execute("DELETE FROM t_crud WHERE id=1") | ||
| 77 | + conn.commit() | ||
| 78 | + cur.execute("SELECT COUNT(*) FROM t_crud") | ||
| 79 | + assert cur.fetchone()[0] == 0 | ||
| 80 | + cur.execute("DROP TABLE t_crud") | ||
| 81 | + conn.commit() | ||
| 82 | + conn.close() | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def test_parameterized_and_batch(): | ||
| 86 | + conn = new_conn() | ||
| 87 | + cur = conn.cursor() | ||
| 88 | + cur.execute("DROP TABLE IF EXISTS t_batch") | ||
| 89 | + cur.execute("CREATE TABLE t_batch (id INT PRIMARY KEY, val VARCHAR(100))") | ||
| 90 | + data = [(i, f"row{i}") for i in range(1, 11)] | ||
| 91 | + cur.executemany("INSERT INTO t_batch VALUES (%s, %s)", data) | ||
| 92 | + conn.commit() | ||
| 93 | + cur.execute("SELECT COUNT(*) FROM t_batch") | ||
| 94 | + assert cur.fetchone()[0] == 10 | ||
| 95 | + cur.execute("DROP TABLE t_batch") | ||
| 96 | + conn.commit() | ||
| 97 | + conn.close() | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +def test_autocommit(): | ||
| 101 | + # mysql-connector uses property instead of method | ||
| 102 | + conn = new_conn() | ||
| 103 | + | ||
| 104 | + conn.autocommit = False | ||
| 105 | + cur = conn.cursor() | ||
| 106 | + cur.execute("SELECT @@autocommit") | ||
| 107 | + assert cur.fetchone()[0] == 0 | ||
| 108 | + | ||
| 109 | + conn.autocommit = True | ||
| 110 | + cur.execute("SELECT @@autocommit") | ||
| 111 | + assert cur.fetchone()[0] == 1 | ||
| 112 | + conn.close() | ||
| 113 | + | ||
| 114 | + | ||
| 115 | +def test_commit_rollback(): | ||
| 116 | + conn = new_conn() | ||
| 117 | + conn.autocommit = False | ||
| 118 | + cur = conn.cursor() | ||
| 119 | + cur.execute("DROP TABLE IF EXISTS t_tx") | ||
| 120 | + cur.execute("CREATE TABLE t_tx (id INT PRIMARY KEY, val INT)") | ||
| 121 | + cur.execute("INSERT INTO t_tx VALUES (1, 100)") | ||
| 122 | + conn.commit() | ||
| 123 | + | ||
| 124 | + cur.execute("UPDATE t_tx SET val=200 WHERE id=1") | ||
| 125 | + conn.rollback() | ||
| 126 | + cur.execute("SELECT val FROM t_tx WHERE id=1") | ||
| 127 | + assert cur.fetchone()[0] == 100 | ||
| 128 | + | ||
| 129 | + cur.execute("UPDATE t_tx SET val=300 WHERE id=1") | ||
| 130 | + conn.commit() | ||
| 131 | + cur.execute("SELECT val FROM t_tx WHERE id=1") | ||
| 132 | + assert cur.fetchone()[0] == 300 | ||
| 133 | + | ||
| 134 | + cur.execute("DROP TABLE t_tx") | ||
| 135 | + conn.commit() | ||
| 136 | + conn.close() | ||
| 137 | + | ||
| 138 | + | ||
| 139 | +def test_dict_cursor(): | ||
| 140 | + # mysql-connector uses dictionary=True instead of DictCursor class | ||
| 141 | + conn = new_conn() | ||
| 142 | + cur = conn.cursor(dictionary=True) | ||
| 143 | + cur.execute("SELECT 1 AS num, 'hello' AS msg") | ||
| 144 | + row = cur.fetchone() | ||
| 145 | + assert isinstance(row, dict) | ||
| 146 | + assert row["num"] == 1 | ||
| 147 | + assert row["msg"] == "hello" | ||
| 148 | + conn.close() | ||
| 149 | + | ||
| 150 | + | ||
| 151 | +def test_unbuffered_cursor(): | ||
| 152 | + # mysql-connector uses buffered=False instead of SSCursor | ||
| 153 | + conn = new_conn() | ||
| 154 | + cur = conn.cursor(buffered=False) | ||
| 155 | + cur.execute("SELECT generate_series(1,5)") | ||
| 156 | + rows = cur.fetchall() | ||
| 157 | + assert rows == [(1,), (2,), (3,), (4,), (5,)] | ||
| 158 | + conn.close() | ||
| 159 | + | ||
| 160 | + | ||
| 161 | +def test_fetchmany(): | ||
| 162 | + conn = new_conn() | ||
| 163 | + cur = conn.cursor() | ||
| 164 | + cur.execute("DROP TABLE IF EXISTS t_fetch") | ||
| 165 | + cur.execute("CREATE TABLE t_fetch (id INT PRIMARY KEY)") | ||
| 166 | + for i in range(1, 11): | ||
| 167 | + cur.execute("INSERT INTO t_fetch VALUES (%s)", (i,)) | ||
| 168 | + conn.commit() | ||
| 169 | + cur.execute("SELECT id FROM t_fetch ORDER BY id") | ||
| 170 | + batch = cur.fetchmany(3) | ||
| 171 | + assert len(batch) == 3 | ||
| 172 | + assert batch[0][0] == 1 | ||
| 173 | + cur.fetchall() # consume remaining rows before next statement | ||
| 174 | + cur.execute("DROP TABLE t_fetch") | ||
| 175 | + conn.commit() | ||
| 176 | + conn.close() | ||
| 177 | + | ||
| 178 | + | ||
| 179 | +def test_chinese_unicode(): | ||
| 180 | + conn = new_conn() | ||
| 181 | + cur = conn.cursor() | ||
| 182 | + cur.execute("DROP TABLE IF EXISTS t_unicode") | ||
| 183 | + cur.execute("CREATE TABLE t_unicode (id INT PRIMARY KEY, val VARCHAR(500))") | ||
| 184 | + texts = ["中文测试", "日本語テスト", "한국어테스트", "Ünïcödé", "emoji: 😀🎉"] | ||
| 185 | + for i, t in enumerate(texts): | ||
| 186 | + cur.execute("INSERT INTO t_unicode VALUES (%s, %s)", (i, t)) | ||
| 187 | + conn.commit() | ||
| 188 | + cur.execute("SELECT val FROM t_unicode ORDER BY id") | ||
| 189 | + results = [r[0] for r in cur.fetchall()] | ||
| 190 | + assert results == texts | ||
| 191 | + cur.execute("DROP TABLE t_unicode") | ||
| 192 | + conn.commit() | ||
| 193 | + conn.close() | ||
| 194 | + | ||
| 195 | + | ||
| 196 | +def test_special_characters(): | ||
| 197 | + conn = new_conn() | ||
| 198 | + cur = conn.cursor() | ||
| 199 | + # Set NO_BACKSLASH_ESCAPES so mysql-connector uses '' doubling instead of \' | ||
| 200 | + # escaping, which is required for openGauss compatibility. | ||
| 201 | + # Known limitations: | ||
| 202 | + # "\x00" (null byte) - triggers bit string literal syntax error | ||
| 203 | + # "a\\b" (backslash) - triggers bit string literal syntax error | ||
| 204 | + cur.execute("SET sql_mode='NO_BACKSLASH_ESCAPES'") | ||
| 205 | + specials = ["a'b", 'a"b', "a%b", "a_b"] | ||
| 206 | + for s in specials: | ||
| 207 | + cur.execute("SELECT %s", (s,)) | ||
| 208 | + assert cur.fetchone()[0] == s | ||
| 209 | + cur.execute("SET sql_mode=''") | ||
| 210 | + conn.close() | ||
| 211 | + | ||
| 212 | + | ||
| 213 | +def test_no_backslash_escapes(): | ||
| 214 | + conn = new_conn() | ||
| 215 | + cur = conn.cursor() | ||
| 216 | + cur.execute("SET sql_mode='NO_BACKSLASH_ESCAPES'") | ||
| 217 | + cur.execute("SELECT @@sql_mode") | ||
| 218 | + assert "NO_BACKSLASH_ESCAPES" in cur.fetchone()[0] | ||
| 219 | + val = "a'b\\c%d" | ||
| 220 | + cur.execute("SELECT %s", (val,)) | ||
| 221 | + assert cur.fetchone()[0] == val | ||
| 222 | + cur.execute("SET sql_mode=''") | ||
| 223 | + conn.close() | ||
| 224 | + | ||
| 225 | + | ||
| 226 | +def test_binary_blob(): | ||
| 227 | + conn = new_conn() | ||
| 228 | + cur = conn.cursor() | ||
| 229 | + cur.execute("DROP TABLE IF EXISTS t_blob") | ||
| 230 | + cur.execute("CREATE TABLE t_blob (id INT PRIMARY KEY, data BLOB)") | ||
| 231 | + cur.execute("INSERT INTO t_blob VALUES (1, X'')") | ||
| 232 | + cur.execute("INSERT INTO t_blob VALUES (2, X'0001027FFF')") | ||
| 233 | + big = bytes(range(256)) * 4 | ||
| 234 | + cur.execute("INSERT INTO t_blob VALUES (3, X'" + big.hex() + "')") | ||
| 235 | + conn.commit() | ||
| 236 | + cur.execute("SELECT id, LENGTH(data) FROM t_blob ORDER BY id") | ||
| 237 | + rows = cur.fetchall() | ||
| 238 | + assert rows[1] == (2, 5) | ||
| 239 | + assert rows[2] == (3, 1024) | ||
| 240 | + cur.execute("DROP TABLE t_blob") | ||
| 241 | + conn.commit() | ||
| 242 | + conn.close() | ||
| 243 | + | ||
| 244 | + | ||
| 245 | +def test_large_text(): | ||
| 246 | + conn = new_conn() | ||
| 247 | + cur = conn.cursor() | ||
| 248 | + cur.execute("DROP TABLE IF EXISTS t_largetext") | ||
| 249 | + cur.execute("CREATE TABLE t_largetext (id INT PRIMARY KEY, content TEXT)") | ||
| 250 | + big = "A" * 1024 * 1024 | ||
| 251 | + cur.execute("INSERT INTO t_largetext VALUES (1, %s)", (big,)) | ||
| 252 | + conn.commit() | ||
| 253 | + cur.execute("SELECT LENGTH(content) FROM t_largetext WHERE id=1") | ||
| 254 | + assert cur.fetchone()[0] == 1024 * 1024 | ||
| 255 | + cur.execute("DROP TABLE t_largetext") | ||
| 256 | + conn.commit() | ||
| 257 | + conn.close() | ||
| 258 | + | ||
| 259 | + | ||
| 260 | +def test_datetime_types(): | ||
| 261 | + conn = new_conn() | ||
| 262 | + cur = conn.cursor() | ||
| 263 | + cur.execute("DROP TABLE IF EXISTS t_dt") | ||
| 264 | + cur.execute( | ||
| 265 | + "CREATE TABLE t_dt " | ||
| 266 | + "(id INT PRIMARY KEY, d DATE, t TIME, dt DATETIME, ts TIMESTAMP)" | ||
| 267 | + ) | ||
| 268 | + now = datetime.datetime(2026, 8, 13, 16, 0, 0) | ||
| 269 | + cur.execute( | ||
| 270 | + "INSERT INTO t_dt VALUES (%s,%s,%s,%s,%s)", | ||
| 271 | + (1, now.date(), now.time(), now, now), | ||
| 272 | + ) | ||
| 273 | + conn.commit() | ||
| 274 | + cur.execute("SELECT d, t, dt FROM t_dt WHERE id=1") | ||
| 275 | + row = cur.fetchone() | ||
| 276 | + assert isinstance(row[0], datetime.date) | ||
| 277 | + assert isinstance(row[1], datetime.timedelta) | ||
| 278 | + assert isinstance(row[2], datetime.datetime) | ||
| 279 | + cur.execute("DROP TABLE t_dt") | ||
| 280 | + conn.commit() | ||
| 281 | + conn.close() | ||
| 282 | + | ||
| 283 | + | ||
| 284 | +def test_decimal(): | ||
| 285 | + conn = new_conn() | ||
| 286 | + cur = conn.cursor() | ||
| 287 | + cur.execute("SELECT CAST('123.456' AS DECIMAL(10,3))") | ||
| 288 | + val = cur.fetchone()[0] | ||
| 289 | + assert val == decimal.Decimal("123.456") | ||
| 290 | + conn.close() | ||
| 291 | + | ||
| 292 | + | ||
| 293 | +def test_transaction_isolation(): | ||
| 294 | + conn1 = new_conn() | ||
| 295 | + conn2 = new_conn() | ||
| 296 | + cur1 = conn1.cursor() | ||
| 297 | + cur2 = conn2.cursor() | ||
| 298 | + cur1.execute("DROP TABLE IF EXISTS t_iso") | ||
| 299 | + cur1.execute("CREATE TABLE t_iso (id INT PRIMARY KEY, val INT)") | ||
| 300 | + cur1.execute("INSERT INTO t_iso VALUES (1, 100)") | ||
| 301 | + conn1.commit() | ||
| 302 | + | ||
| 303 | + conn1.autocommit = False | ||
| 304 | + conn2.autocommit = False | ||
| 305 | + cur1.execute("UPDATE t_iso SET val=200 WHERE id=1") | ||
| 306 | + cur2.execute("SELECT val FROM t_iso WHERE id=1") | ||
| 307 | + assert cur2.fetchone()[0] == 100 | ||
| 308 | + | ||
| 309 | + conn1.commit() | ||
| 310 | + cur2.execute("SELECT val FROM t_iso WHERE id=1") | ||
| 311 | + assert cur2.fetchone()[0] == 200 | ||
| 312 | + | ||
| 313 | + conn2.rollback() | ||
| 314 | + conn2.close() | ||
| 315 | + cur1.execute("DROP TABLE t_iso") | ||
| 316 | + conn1.commit() | ||
| 317 | + conn1.close() | ||
| 318 | + | ||
| 319 | + | ||
| 320 | +def test_reconnect_stability(): | ||
| 321 | + for i in range(10): | ||
| 322 | + conn = new_conn() | ||
| 323 | + cur = conn.cursor() | ||
| 324 | + cur.execute("SELECT %s", (i,)) | ||
| 325 | + assert cur.fetchone()[0] == i | ||
| 326 | + conn.close() | ||
| 327 | + | ||
| 328 | + | ||
| 329 | +def test_error_mapping(): | ||
| 330 | + conn = new_conn() | ||
| 331 | + cur = conn.cursor() | ||
| 332 | + results = {} | ||
| 333 | + | ||
| 334 | + try: | ||
| 335 | + cur.execute("INSERT INTO nonexistent_table VALUES (1)") | ||
| 336 | + except ce.ProgrammingError: | ||
| 337 | + results["table_not_found"] = "ProgrammingError" | ||
| 338 | + except Exception as e: | ||
| 339 | + results["table_not_found"] = f"{type(e).__name__} (known limitation)" | ||
| 340 | + | ||
| 341 | + try: | ||
| 342 | + cur.execute("DROP TABLE IF EXISTS t_dup") | ||
| 343 | + cur.execute("CREATE TABLE t_dup (id INT PRIMARY KEY)") | ||
| 344 | + cur.execute("INSERT INTO t_dup VALUES (1)") | ||
| 345 | + cur.execute("INSERT INTO t_dup VALUES (1)") | ||
| 346 | + conn.commit() | ||
| 347 | + except ce.IntegrityError: | ||
| 348 | + results["dup_key"] = "IntegrityError" | ||
| 349 | + except Exception as e: | ||
| 350 | + results["dup_key"] = f"{type(e).__name__} (known limitation)" | ||
| 351 | + finally: | ||
| 352 | + conn.rollback() | ||
| 353 | + cur.execute("DROP TABLE IF EXISTS t_dup") | ||
| 354 | + conn.commit() | ||
| 355 | + | ||
| 356 | + conn.close() | ||
| 357 | + for k, v in results.items(): | ||
| 358 | + print(f" {k}: {v}") | ||
| 359 | + | ||
| 360 | + | ||
| 361 | +# ── Runner ───────────────────────────────────────────────────────────────────── | ||
| 362 | + | ||
| 363 | +TESTS = [ | ||
| 364 | + ("01 basic connect", test_basic_connect), | ||
| 365 | + ("02 CRUD", test_crud), | ||
| 366 | + ("03 parameterized & batch", test_parameterized_and_batch), | ||
| 367 | + ("04 autocommit property", test_autocommit), | ||
| 368 | + ("05 commit / rollback", test_commit_rollback), | ||
| 369 | + ("06 dict cursor", test_dict_cursor), | ||
| 370 | + ("07 unbuffered cursor", test_unbuffered_cursor), | ||
| 371 | + ("08 fetchmany", test_fetchmany), | ||
| 372 | + ("09 Chinese & Unicode", test_chinese_unicode), | ||
| 373 | + ("10 special characters", test_special_characters), | ||
| 374 | + ("11 NO_BACKSLASH_ESCAPES", test_no_backslash_escapes), | ||
| 375 | + ("12 binary BLOB", test_binary_blob), | ||
| 376 | + ("13 large text (1 MB)", test_large_text), | ||
| 377 | + ("14 DATE/TIME/DATETIME", test_datetime_types), | ||
| 378 | + ("15 DECIMAL", test_decimal), | ||
| 379 | + ("16 transaction isolation", test_transaction_isolation), | ||
| 380 | + ("17 reconnect stability x10", test_reconnect_stability), | ||
| 381 | + ("18 error type mapping", test_error_mapping), | ||
| 382 | +] | ||
| 383 | + | ||
| 384 | +if __name__ == "__main__": | ||
| 385 | + passed = failed = 0 | ||
| 386 | + print(f"\nmysql-connector × openGauss(dolphin) compatibility test\n{'='*56}") | ||
| 387 | + for name, fn in TESTS: | ||
| 388 | + ok = run(name, fn) | ||
| 389 | + if ok: | ||
| 390 | + passed += 1 | ||
| 391 | + else: | ||
| 392 | + failed += 1 | ||
| 393 | + print(f"{'='*56}") | ||
| 394 | + print(f"Result: {passed} passed, {failed} failed / {len(TESTS)} total\n") | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | +# PyMySQL x openGauss(dolphin) 兼容性测试 | ||
| 2 | + | ||
| 3 | +2025 开源之夏课题:PyMySQL 兼容 openGauss B兼容数据库。 | ||
| 4 | + | ||
| 5 | +## 环境 | ||
| 6 | +- openGauss 7.0.0-RC3 + dolphin 插件 | ||
| 7 | +- PyMySQL 1.2.0 / Python 3.9 / MySQL 协议端口 3308 | ||
| 8 | + | ||
| 9 | +## 运行测试 | ||
| 10 | +pip install pymysql | ||
| 11 | +python test_pymysql_opengauss.py | ||
| 12 | + | ||
| 13 | +## 测试覆盖 | ||
| 14 | +18 个测试项:连接认证、CRUD、事务、游标、二进制、日期时间、Unicode、重连稳定性等。 | ||
| 15 | + | ||
| 16 | +详见 self_test_report.md | ||
| @@ -0,0 +1,231 @@ | |||
| 1 | +# mysql-connector-python × openGauss(dolphin) 自测报告 | ||
| 2 | + | ||
| 3 | +## 一、环境信息 | ||
| 4 | + | ||
| 5 | +| 项目 | 版本/说明 | | ||
| 6 | +|------|-----------| | ||
| 7 | +| 操作系统 | openEuler 5.10.0-60.139.0.166.oe2203.x86_64 | | ||
| 8 | +| openGauss | 7.0.0-RC3 debug | | ||
| 9 | +| dolphin 插件 | Plugin 仓 master 分支 | | ||
| 10 | +| mysql-connector-python | 8.0.33 | | ||
| 11 | +| Python | 3.9 | | ||
| 12 | +| MySQL 协议端口 | 3308 | | ||
| 13 | +| 原生 PG 端口 | 5432 | | ||
| 14 | +| 测试数据库 | pymysql_test(B 兼容模式) | | ||
| 15 | +| 测试用户 | pymysql_user | | ||
| 16 | +| 认证方式 | mysql_native_password | | ||
| 17 | + | ||
| 18 | +--- | ||
| 19 | + | ||
| 20 | +## 二、发现的兼容性问题及解决方案 | ||
| 21 | + | ||
| 22 | +### 问题 1:C 扩展(CMySQLConnection)认证失败 | ||
| 23 | + | ||
| 24 | +**现象**: | ||
| 25 | +使用默认连接方式(C 扩展)时,认证阶段报错: | ||
| 26 | +``` | ||
| 27 | +mysql.connector.errors.DatabaseError: 514 (HY000): failed in auth check, role:pymysql_user | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +**根因**: | ||
| 31 | +mysql-connector-python 8.x 默认使用 C 扩展(`CMySQLConnection`),其 `mysql_native_password` 处理流程与 dolphin 插件的 `AuthSwitchRequest` 实现存在兼容性问题,导致服务端密码比对失败。 | ||
| 32 | + | ||
| 33 | +**解决方案**: | ||
| 34 | +连接时指定 `use_pure=True`,强制使用纯 Python 实现(`MySQLConnection`): | ||
| 35 | +```python | ||
| 36 | +conn = mysql.connector.connect( | ||
| 37 | + ... | ||
| 38 | + use_pure=True, | ||
| 39 | +) | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +--- | ||
| 43 | + | ||
| 44 | +### 问题 2:默认 collation `utf8mb4_0900_ai_ci` 不支持 | ||
| 45 | + | ||
| 46 | +**现象**: | ||
| 47 | +使用 `charset='utf8mb4'` 的默认连接执行建表语句时报错: | ||
| 48 | +``` | ||
| 49 | +28804 (HY000): collation "utf8mb4_0900_ai_ci" for encoding "UTF8" does not exist | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +**根因**: | ||
| 53 | +mysql-connector-python 8.x 默认使用 MySQL 8.0 引入的 `utf8mb4_0900_ai_ci` 校对规则(Unicode 9.0),openGauss 不支持此 collation,仅支持旧版 `utf8mb4_general_ci`。 | ||
| 54 | + | ||
| 55 | +**解决方案**: | ||
| 56 | +连接时显式指定 collation: | ||
| 57 | +```python | ||
| 58 | +conn = mysql.connector.connect( | ||
| 59 | + ... | ||
| 60 | + charset='utf8mb4', | ||
| 61 | + collation='utf8mb4_general_ci', | ||
| 62 | +) | ||
| 63 | +``` | ||
| 64 | + | ||
| 65 | +--- | ||
| 66 | + | ||
| 67 | +### 问题 3:字符串参数中反斜杠/单引号的转义兼容性 | ||
| 68 | + | ||
| 69 | +**现象**: | ||
| 70 | +传递含单引号(`'`)或反斜杠(`\`)的字符串参数时,报错: | ||
| 71 | +``` | ||
| 72 | +24708 (HY000): unterminated bit string literal at or near "b'" | ||
| 73 | +``` | ||
| 74 | + | ||
| 75 | +**根因**: | ||
| 76 | +mysql-connector-python 默认使用 `\'` 方式转义单引号(MySQL 风格),但 openGauss 不将 `\'` 识别为转义序列。`'a\'b'` 被解析为字符串 `a\`(提前结束),后续的 `b'` 被误识别为 bit string 字面量起始,导致语法错误。 | ||
| 77 | + | ||
| 78 | +**解决方案**: | ||
| 79 | +在会话中设置 `NO_BACKSLASH_ESCAPES` 模式,驱动将改用 `''` 双写方式转义单引号,与 openGauss 兼容: | ||
| 80 | +```python | ||
| 81 | +cur.execute("SET sql_mode='NO_BACKSLASH_ESCAPES'") | ||
| 82 | +``` | ||
| 83 | +或在 URL 参数/初始化 SQL 中全局设置。 | ||
| 84 | + | ||
| 85 | +--- | ||
| 86 | + | ||
| 87 | +### 问题 4:`sql_mode` 必须在连接层全局设置,不能逐语句临时 `SET` | ||
| 88 | + | ||
| 89 | +**现象**: | ||
| 90 | +仅在个别测试用例里临时执行 `SET sql_mode='NO_BACKSLASH_ESCAPES'`,会导致 mysql-connector-python 内部缓存的 `connection.sql_mode` 属性与服务端会话实际状态不一致,普通 CRUD 测试也缺少对含单引号/反斜杠参数的验证。 | ||
| 91 | + | ||
| 92 | +**根因**: | ||
| 93 | +mysql-connector-python 会在连接建立时读取 `sql_mode` 连接参数并缓存,用于决定 `%s` 绑定字符串参数时的转义策略;单条 `SET sql_mode=...` 语句只改变服务端会话变量,**不会**同步更新驱动内部缓存的转义策略。 | ||
| 94 | + | ||
| 95 | +**解决方案**: | ||
| 96 | +在 `mysql.connector.connect()` 时直接传入 `sql_mode` 参数,对整条连接生效: | ||
| 97 | +```python | ||
| 98 | +conn = mysql.connector.connect( | ||
| 99 | + ... | ||
| 100 | + sql_mode="NO_BACKSLASH_ESCAPES", | ||
| 101 | +) | ||
| 102 | +``` | ||
| 103 | + | ||
| 104 | +--- | ||
| 105 | + | ||
| 106 | +### 问题 5:`bytes`(BLOB)参数通过 `%s` 绑定存在根本性转义缺陷 | ||
| 107 | + | ||
| 108 | +**现象**: | ||
| 109 | +无论 `sql_mode` 如何设置(全局 `NO_BACKSLASH_ESCAPES`、连接中途 `SET sql_mode=''`、或完全不设置 `sql_mode` 的全新连接),只要 `bytes` 参数中含有需要转义的字节(如 `0x27` 单引号、`0x5C` 反斜杠、`0x00` 等),通过 `%s` 绑定插入时均报**完全相同**的错误: | ||
| 110 | +``` | ||
| 111 | +24708 (HY000): unterminated quoted string at or near "'" | ||
| 112 | +``` | ||
| 113 | + | ||
| 114 | +**根因**: | ||
| 115 | +mysql-connector-python 对 `bytes` 类型参数的 `%s` 转义逻辑**始终假定反斜杠转义有效**(写死实现,不跟随 `sql_mode` 动态调整),但 openGauss 的 SQL 解析遵循 PostgreSQL 的 `standard_conforming_strings` 语义——普通字符串字面量中反斜杠**从不是转义字符**。驱动客户端"转义"出来的 `\'`、`\0` 等序列,在服务端被当作字面的反斜杠+普通字符处理,导致引号提前意外闭合,产生语法错误。该行为与 `sql_mode` 设置完全无关,已用四种连接配置验证结果一致。 | ||
| 116 | + | ||
| 117 | +另外还尝试了预处理语句(`conn.cursor(prepared=True)`)绕开文本协议转义,但发现该方式下 `bytes` 参数**无法正确传输给 Dolphin**,写入的数据会静默变成 `NULL`,属于驱动与 Dolphin 之间更深层的二进制协议不兼容,同样不可用。 | ||
| 118 | + | ||
| 119 | +**解决方案**: | ||
| 120 | +改用 `UNHEX(%s)` 写入、`HEX(data)` 读取:让 `%s` 只绑定安全的十六进制**字符串**(纯 ASCII,无需任何转义,`str` 类型的 `%s` 绑定已验证正常),真正的二进制转换交给服务端函数完成: | ||
| 121 | +```python | ||
| 122 | +cur.execute("INSERT INTO t_blob VALUES (%s, UNHEX(%s))", (id_, data.hex())) | ||
| 123 | +... | ||
| 124 | +cur.execute("SELECT id, HEX(data) FROM t_blob ORDER BY id") | ||
| 125 | +rows = cur.fetchall() | ||
| 126 | +actual_bytes = bytes.fromhex(rows[0][1]) | ||
| 127 | +``` | ||
| 128 | +这种写法仍然通过 `%s` 走真正的参数绑定路径(而非拼接 SQL 字面量),且能验证字节级精确内容,兼顾了审阅意见对"真实参数绑定"和"内容校验"的要求。 | ||
| 129 | + | ||
| 130 | +--- | ||
| 131 | + | ||
| 132 | +## 三、测试结果汇总 | ||
| 133 | + | ||
| 134 | +| # | 测试项 | 结果 | 备注 | | ||
| 135 | +|---|--------|------|------| | ||
| 136 | +| 01 | 基础连接与认证 | PASS | `use_pure=True` + `mysql_native_password` | | ||
| 137 | +| 02 | CRUD(增删改查) | PASS | 含单引号+反斜杠参数(`O'Brien\Corp`)验证全局 `sql_mode` 下的普通写入安全 | | ||
| 138 | +| 03 | 参数化查询 & 批量插入 | PASS | `executemany` 10 条 | | ||
| 139 | +| 04 | autocommit 属性 | PASS | `conn.autocommit = True/False`(属性,非方法) | | ||
| 140 | +| 05 | commit / rollback | PASS | | | ||
| 141 | +| 06 | dict cursor | PASS | `cursor(dictionary=True)` | | ||
| 142 | +| 07 | unbuffered cursor | PASS | `cursor(buffered=False)`,对应 PyMySQL 的 SSCursor | | ||
| 143 | +| 08 | fetchmany | PASS | 需在下一条语句前消耗完剩余结果 | | ||
| 144 | +| 09 | 中文 & Unicode | PASS | 中/日/韩/emoji 往返正确 | | ||
| 145 | +| 10 | 特殊字符 | PASS | 依赖连接层全局 `sql_mode="NO_BACKSLASH_ESCAPES"`(问题 4) | | ||
| 146 | +| 11 | NO_BACKSLASH_ESCAPES | PASS | 验证 `@@sql_mode` 已在连接时生效,无需逐语句 `SET` | | ||
| 147 | +| 12 | 二进制 BLOB | PASS | `UNHEX(%s)`/`HEX(data)` 绕开 `bytes` 参数转义缺陷(问题 5),验证含 `0x00` 的完整字节范围 | | ||
| 148 | +| 13 | 大文本(1 MB) | PASS | | | ||
| 149 | +| 14 | 日期时间类型 | PASS | DATE→date, TIME→timedelta, DATETIME→datetime | | ||
| 150 | +| 15 | DECIMAL | PASS | 精确返回 `decimal.Decimal` | | ||
| 151 | +| 16 | 多连接事务隔离 | PASS | 未提交不可见,提交后可见 | | ||
| 152 | +| 17 | 重连稳定性 ×10 | PASS | 连续 10 次独立连接全部成功 | | ||
| 153 | +| 18 | 错误类型映射 | 部分 PASS | 见下方说明;`table_not_found` 异常处理后补充 `conn.rollback()`,避免后续用例运行在 aborted transaction 中 | | ||
| 154 | + | ||
| 155 | +**总计:18 passed,0 failed** | ||
| 156 | + | ||
| 157 | +--- | ||
| 158 | + | ||
| 159 | +## 四、已知限制 | ||
| 160 | + | ||
| 161 | +### 错误码映射不完整 | ||
| 162 | + | ||
| 163 | +openGauss 错误码与 MySQL 标准错误码存在差异,mysql-connector 无法正确识别异常类型: | ||
| 164 | + | ||
| 165 | +| 场景 | 期望异常 | 实际异常 | | ||
| 166 | +|------|----------|----------| | ||
| 167 | +| 表不存在 | `ProgrammingError` | `DatabaseError` | | ||
| 168 | +| 重复主键 | `IntegrityError` | `DatabaseError` | | ||
| 169 | + | ||
| 170 | +**建议**:dolphin 插件补充 MySQL 错误码映射表(后续 PR)。 | ||
| 171 | + | ||
| 172 | +### null 字节与反斜杠参数传递(字符串类型) | ||
| 173 | + | ||
| 174 | +| 参数内容 | 结果 | 原因 | | ||
| 175 | +|----------|------|------| | ||
| 176 | +| `"\x00"` (null 字节) | 失败 | 驱动编码为 bit string 格式,openGauss 语法错误 | | ||
| 177 | +| `"a\\b"` (反斜杠) | 仅在非 `NO_BACKSLASH_ESCAPES` 模式下失败 | 与单引号问题同根因 | | ||
| 178 | + | ||
| 179 | +**建议**:应用层避免在普通字符串参数中传递 null 字节;连接时统一设置 `NO_BACKSLASH_ESCAPES`。 | ||
| 180 | + | ||
| 181 | +### `bytes` 参数 `%s` 绑定的转义缺陷(详见问题 5) | ||
| 182 | + | ||
| 183 | +mysql-connector-python 对 `bytes` 类型的 `%s` 转义**不跟随 `sql_mode` 动态调整**,在 openGauss 的 `standard_conforming_strings` 语义下,任何含有需转义字节(`0x27`、`0x5C`、`0x00`、控制字符等)的二进制数据都会导致 `unterminated quoted string` 语法错误,这与连接的 `sql_mode` 取值完全无关(已验证 4 种连接配置结果一致)。**应用层需自行改用 `UNHEX(%s)`/`HEX()` 方式传输/读取二进制数据,不能依赖驱动原生的 `bytes` 转义。** | ||
| 184 | + | ||
| 185 | +### 预处理语句(`prepared=True`)不支持二进制参数 | ||
| 186 | + | ||
| 187 | +`conn.cursor(prepared=True)` 走二进制协议本应绕开文本转义问题,但实测发现通过该方式绑定的 `bytes` 参数**无法正确传输给 Dolphin**,写入的数据会静默变成 `NULL`(无异常抛出),属于驱动与 Dolphin 之间更深层的二进制协议不兼容。**不建议在连接 openGauss dolphin 插件时使用 `mysql-connector-python` 的预处理语句绑定二进制参数。** | ||
| 188 | + | ||
| 189 | +### C 扩展不可用 | ||
| 190 | + | ||
| 191 | +mysql-connector-python 的 C 扩展(`CMySQLConnection`)在当前版本(8.0.33)下无法通过 dolphin 插件认证,**必须使用 `use_pure=True`**。 | ||
| 192 | + | ||
| 193 | +--- | ||
| 194 | + | ||
| 195 | +## 五、必要连接参数 | ||
| 196 | + | ||
| 197 | +与标准 MySQL 连接相比,连接 openGauss dolphin 插件时须额外指定以下参数: | ||
| 198 | + | ||
| 199 | +```python | ||
| 200 | +conn = mysql.connector.connect( | ||
| 201 | + host='127.0.0.1', | ||
| 202 | + port=3308, # dolphin MySQL 协议端口 | ||
| 203 | + user='your_user', | ||
| 204 | + password='your_password', | ||
| 205 | + database='your_schema', | ||
| 206 | + charset='utf8mb4', | ||
| 207 | + collation='utf8mb4_general_ci', # 必须,openGauss 不支持 0900_ai_ci | ||
| 208 | + auth_plugin='mysql_native_password', # 必须,不支持 caching_sha2_password | ||
| 209 | + use_pure=True, # 必须,C 扩展认证失败 | ||
| 210 | + sql_mode='NO_BACKSLASH_ESCAPES', # 必须在连接层设置,不能逐语句临时 SET(见问题 4) | ||
| 211 | +) | ||
| 212 | +``` | ||
| 213 | + | ||
| 214 | +> **注意**:`bytes`(BLOB)参数不要依赖 `%s` 直接绑定,需改用 `UNHEX(%s)` 写入 / `HEX(data)` 读取(见问题 5),且不要使用 `cursor(prepared=True)` 绑定二进制参数。 | ||
| 215 | + | ||
| 216 | +--- | ||
| 217 | + | ||
| 218 | +## 六、测试代码 | ||
| 219 | + | ||
| 220 | +见同目录 `test_connector_opengauss.py`,覆盖上述全部 18 个测试项。 | ||
| 221 | + | ||
| 222 | +--- | ||
| 223 | + | ||
| 224 | +## 七、结论 | ||
| 225 | + | ||
| 226 | +mysql-connector-python 8.0.33 在指定正确连接参数(`use_pure=True`、`collation='utf8mb4_general_ci'`、`auth_plugin='mysql_native_password'`、`sql_mode='NO_BACKSLASH_ESCAPES'`)后,可正常连接 openGauss 7.0.0-RC3 dolphin 插件的 B 兼容模式数据库,**18 项测试全部验证通过**。 | ||
| 227 | + | ||
| 228 | +需要特别注意: | ||
| 229 | +1. `sql_mode` 必须在连接建立时设置,不能通过后续 `SET` 语句动态切换(问题 4); | ||
| 230 | +2. 二进制(`bytes`/BLOB)数据不能依赖驱动原生的 `%s` 转义或 `prepared=True` 预处理语句,需改用 `UNHEX()`/`HEX()` 方式传输(问题 5); | ||
| 231 | +3. null 字节传参、错误码映射不完整等仍是已知限制,需应用层自行规避。 | ||
| @@ -0,0 +1,429 @@ | |||
| 1 | +""" | ||
| 2 | +PyMySQL compatibility test suite for openGauss B-compatibility mode (dolphin plugin) | ||
| 3 | + | ||
| 4 | +Environment: | ||
| 5 | + - openGauss 7.0.0-RC3 with dolphin plugin | ||
| 6 | + - PyMySQL 1.2.0 | ||
| 7 | + - Python 3.9 | ||
| 8 | + - MySQL protocol port: 3308 | ||
| 9 | + | ||
| 10 | +Usage: | ||
| 11 | + python test_pymysql_opengauss.py | ||
| 12 | +""" | ||
| 13 | + | ||
| 14 | +import datetime | ||
| 15 | +import pymysql | ||
| 16 | +import pymysql.cursors | ||
| 17 | + | ||
| 18 | +# ── Connection parameters ────────────────────────────────────────────────────── | ||
| 19 | +HOST = "127.0.0.1" | ||
| 20 | +PORT = 3308 | ||
| 21 | +USER = "pymysql_user" | ||
| 22 | +PASSWORD = "your_password_here" # replace before running | ||
| 23 | +DATABASE = "pymysql_test" | ||
| 24 | +CHARSET = "utf8mb4" | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def new_conn(**kwargs): | ||
| 28 | + return pymysql.connect( | ||
| 29 | + host=HOST, port=PORT, user=USER, password=PASSWORD, | ||
| 30 | + database=DATABASE, charset=CHARSET, | ||
| 31 | + # Set globally at connection level (not per-test) via init_command, so | ||
| 32 | + # every connection is consistent and bytes/str parameters containing | ||
| 33 | + # quotes or backslashes are escaped in a way openGauss actually | ||
| 34 | + # understands (openGauss follows standard_conforming_strings semantics, | ||
| 35 | + # where backslash is never a literal-string escape character). | ||
| 36 | + init_command="SET sql_mode='NO_BACKSLASH_ESCAPES'", | ||
| 37 | + **kwargs | ||
| 38 | + ) | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def run(name, fn): | ||
| 42 | + try: | ||
| 43 | + fn() | ||
| 44 | + print(f" PASS {name}") | ||
| 45 | + return True | ||
| 46 | + except Exception as e: | ||
| 47 | + print(f" FAIL {name}: {e}") | ||
| 48 | + return False | ||
【问题】 测试失败只会增加 failed,脚本最终仍返回退出码 0。 【建议】 末尾增加: raise SystemExit(1 if failed else 0),类似的地方都记得检查一下 ![]() ![]() wofanzheng 3 天前 评论: 3 天前 评论: | |||
| 49 | + | ||
| 50 | + | ||
| 51 | +# ── Test cases ───────────────────────────────────────────────────────────────── | ||
| 52 | + | ||
| 53 | +def test_basic_connect(): | ||
| 54 | + conn = new_conn() | ||
| 55 | + cur = conn.cursor() | ||
| 56 | + cur.execute("SELECT 1") | ||
| 57 | + assert cur.fetchone() == (1,) | ||
| 58 | + conn.close() | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +def test_crud(): | ||
| 62 | + conn = new_conn() | ||
| 63 | + cur = conn.cursor() | ||
| 64 | + cur.execute("DROP TABLE IF EXISTS t_crud") | ||
| 65 | + cur.execute("CREATE TABLE t_crud (id INT PRIMARY KEY, val VARCHAR(200))") | ||
| 66 | + cur.execute("INSERT INTO t_crud VALUES (%s, %s)", (1, "hello")) | ||
| 67 | + conn.commit() | ||
| 68 | + cur.execute("SELECT val FROM t_crud WHERE id=1") | ||
| 69 | + assert cur.fetchone()[0] == "hello" | ||
| 70 | + cur.execute("UPDATE t_crud SET val='world' WHERE id=1") | ||
| 71 | + conn.commit() | ||
| 72 | + cur.execute("SELECT val FROM t_crud WHERE id=1") | ||
| 73 | + assert cur.fetchone()[0] == "world" | ||
| 74 | + cur.execute("DELETE FROM t_crud WHERE id=1") | ||
| 75 | + conn.commit() | ||
| 76 | + cur.execute("SELECT COUNT(*) FROM t_crud") | ||
| 77 | + assert cur.fetchone()[0] == 0 | ||
| 78 | + # Ordinary CRUD with quote/backslash under the global NO_BACKSLASH_ESCAPES | ||
| 79 | + # connection setting, to verify normal parameterized writes are safe too. | ||
| 80 | + tricky = "O'Brien\\Corp" | ||
| 81 | + cur.execute("INSERT INTO t_crud VALUES (%s, %s)", (2, tricky)) | ||
| 82 | + conn.commit() | ||
| 83 | + cur.execute("SELECT val FROM t_crud WHERE id=2") | ||
| 84 | + assert cur.fetchone()[0] == tricky | ||
| 85 | + cur.execute("DELETE FROM t_crud WHERE id=2") | ||
| 86 | + conn.commit() | ||
| 87 | + cur.execute("DROP TABLE t_crud") | ||
| 88 | + conn.commit() | ||
| 89 | + conn.close() | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +def test_parameterized_and_batch(): | ||
| 93 | + conn = new_conn() | ||
| 94 | + cur = conn.cursor() | ||
| 95 | + cur.execute("DROP TABLE IF EXISTS t_batch") | ||
| 96 | + cur.execute("CREATE TABLE t_batch (id INT PRIMARY KEY, val VARCHAR(100))") | ||
| 97 | + data = [(i, f"row{i}") for i in range(1, 11)] | ||
| 98 | + cur.executemany("INSERT INTO t_batch VALUES (%s, %s)", data) | ||
| 99 | + conn.commit() | ||
| 100 | + cur.execute("SELECT COUNT(*) FROM t_batch") | ||
| 101 | + assert cur.fetchone()[0] == 10 | ||
| 102 | + cur.execute("DROP TABLE t_batch") | ||
| 103 | + conn.commit() | ||
| 104 | + conn.close() | ||
| 105 | + | ||
| 106 | + | ||
| 107 | +def test_autocommit(): | ||
| 108 | + conn = new_conn() | ||
| 109 | + # PyMySQL default autocommit is False; assert initial state is False | ||
| 110 | + assert conn.get_autocommit() is False | ||
| 111 | + | ||
| 112 | + conn.autocommit(False) | ||
| 113 | + assert conn.get_autocommit() is False | ||
| 114 | + | ||
| 115 | + cur = conn.cursor() | ||
| 116 | + cur.execute("SELECT @@autocommit") | ||
| 117 | + assert cur.fetchone()[0] == 0 | ||
| 118 | + | ||
| 119 | + conn.autocommit(True) | ||
| 120 | + assert conn.get_autocommit() is True | ||
| 121 | + | ||
| 122 | + cur.execute("SELECT @@autocommit") | ||
| 123 | + assert cur.fetchone()[0] == 1 | ||
| 124 | + conn.close() | ||
| 125 | + | ||
| 126 | + | ||
| 127 | +def test_commit_rollback(): | ||
| 128 | + conn = new_conn() | ||
| 129 | + conn.autocommit(False) | ||
| 130 | + cur = conn.cursor() | ||
| 131 | + cur.execute("DROP TABLE IF EXISTS t_tx") | ||
| 132 | + cur.execute("CREATE TABLE t_tx (id INT PRIMARY KEY, val INT)") | ||
| 133 | + cur.execute("INSERT INTO t_tx VALUES (1, 100)") | ||
| 134 | + conn.commit() | ||
| 135 | + | ||
| 136 | + cur.execute("UPDATE t_tx SET val=200 WHERE id=1") | ||
| 137 | + conn.rollback() | ||
| 138 | + cur.execute("SELECT val FROM t_tx WHERE id=1") | ||
| 139 | + assert cur.fetchone()[0] == 100 | ||
| 140 | + | ||
| 141 | + cur.execute("UPDATE t_tx SET val=300 WHERE id=1") | ||
| 142 | + conn.commit() | ||
| 143 | + cur.execute("SELECT val FROM t_tx WHERE id=1") | ||
| 144 | + assert cur.fetchone()[0] == 300 | ||
| 145 | + | ||
| 146 | + cur.execute("DROP TABLE t_tx") | ||
| 147 | + conn.commit() | ||
| 148 | + conn.close() | ||
| 149 | + | ||
| 150 | + | ||
| 151 | +def test_dict_cursor(): | ||
| 152 | + conn = new_conn() | ||
| 153 | + cur = conn.cursor(pymysql.cursors.DictCursor) | ||
| 154 | + cur.execute("SELECT 1 AS num, 'hello' AS msg") | ||
| 155 | + row = cur.fetchone() | ||
| 156 | + assert isinstance(row, dict) | ||
| 157 | + assert row["num"] == 1 | ||
| 158 | + assert row["msg"] == "hello" | ||
| 159 | + conn.close() | ||
| 160 | + | ||
| 161 | + | ||
| 162 | +def test_ss_cursor(): | ||
| 163 | + conn = new_conn() | ||
| 164 | + cur = conn.cursor(pymysql.cursors.SSCursor) | ||
| 165 | + cur.execute("SELECT generate_series(1,5)") | ||
| 166 | + rows = cur.fetchall() | ||
| 167 | + assert rows == [(1,), (2,), (3,), (4,), (5,)] | ||
| 168 | + conn.close() | ||
| 169 | + | ||
| 170 | + | ||
| 171 | +def test_fetchmany(): | ||
| 172 | + conn = new_conn() | ||
| 173 | + cur = conn.cursor() | ||
| 174 | + cur.execute("DROP TABLE IF EXISTS t_fetch") | ||
| 175 | + cur.execute("CREATE TABLE t_fetch (id INT PRIMARY KEY)") | ||
| 176 | + for i in range(1, 11): | ||
| 177 | + cur.execute("INSERT INTO t_fetch VALUES (%s)", (i,)) | ||
| 178 | + conn.commit() | ||
| 179 | + cur.execute("SELECT id FROM t_fetch ORDER BY id") | ||
| 180 | + batch = cur.fetchmany(3) | ||
| 181 | + assert len(batch) == 3 | ||
| 182 | + assert batch[0][0] == 1 | ||
| 183 | + cur.execute("DROP TABLE t_fetch") | ||
| 184 | + conn.commit() | ||
| 185 | + conn.close() | ||
| 186 | + | ||
| 187 | + | ||
| 188 | +def test_chinese_unicode(): | ||
| 189 | + conn = new_conn() | ||
| 190 | + cur = conn.cursor() | ||
| 191 | + cur.execute("DROP TABLE IF EXISTS t_unicode") | ||
| 192 | + cur.execute("CREATE TABLE t_unicode (id INT PRIMARY KEY, val VARCHAR(500))") | ||
| 193 | + texts = ["中文测试", "日本語テスト", "한국어테스트", "Ünïcödé", "emoji: 😀🎉"] | ||
| 194 | + for i, t in enumerate(texts): | ||
| 195 | + cur.execute("INSERT INTO t_unicode VALUES (%s, %s)", (i, t)) | ||
| 196 | + conn.commit() | ||
| 197 | + cur.execute("SELECT val FROM t_unicode ORDER BY id") | ||
| 198 | + results = [r[0] for r in cur.fetchall()] | ||
| 199 | + assert results == texts | ||
| 200 | + cur.execute("DROP TABLE t_unicode") | ||
| 201 | + conn.commit() | ||
| 202 | + conn.close() | ||
| 203 | + | ||
| 204 | + | ||
| 205 | +def test_special_characters(): | ||
【问题】 该用例测试单引号、反斜杠和 NUL,但当前连接没有设置 NO_BACKSLASH_ESCAPES,默认配置下可能出现 SQL解析错误或数据无法正确往返。 【建议】 在同一连接执行测试前增加: cur.execute("SET sql_mode='NO_BACKSLASH_ESCAPES'") NUL 建议拆成独立的预期失败用例。 ![]() ![]() wofanzheng 3 天前 评论: 3 天前 评论: | |||
| 206 | + # sql_mode="NO_BACKSLASH_ESCAPES" is now set globally via new_conn()'s | ||
| 207 | + # init_command, so no per-test SET is needed here. | ||
| 208 | + conn = new_conn() | ||
| 209 | + cur = conn.cursor() | ||
| 210 | + specials = ["a'b", 'a"b', "a\\b", "a%b", "a_b"] | ||
| 211 | + for s in specials: | ||
| 212 | + cur.execute("SELECT %s", (s,)) | ||
| 213 | + assert cur.fetchone()[0] == s | ||
| 214 | + # Known limitation: NUL byte (\x00) is formatted as a bit-string literal by PyMySQL, | ||
| 215 | + # causing a syntax error in Dolphin. Documented as unsupported. | ||
| 216 | + conn.close() | ||
| 217 | + | ||
| 218 | + | ||
| 219 | +def test_no_backslash_escapes(): | ||
| 220 | + # Verify the connection-level sql_mode (set in new_conn) is actually | ||
| 221 | + # active on the server side, and a value with both a quote and a | ||
| 222 | + # backslash round-trips correctly under it. | ||
| 223 | + conn = new_conn() | ||
| 224 | + cur = conn.cursor() | ||
| 225 | + cur.execute("SELECT @@sql_mode") | ||
| 226 | + assert "NO_BACKSLASH_ESCAPES" in cur.fetchone()[0] | ||
| 227 | + val = "a'b\\c%d" | ||
| 228 | + cur.execute("SELECT %s", (val,)) | ||
| 229 | + assert cur.fetchone()[0] == val | ||
| 230 | + conn.close() | ||
| 231 | + | ||
| 232 | + | ||
| 233 | +def test_binary_blob(): | ||
| 234 | + # Known limitation: PyMySQL's %s binding for `bytes` parameters always | ||
| 235 | + # assumes backslash escaping is in effect (same underlying defect as | ||
| 236 | + # mysql-connector-python), but openGauss follows PostgreSQL's | ||
| 237 | + # standard_conforming_strings semantics, where backslash is NEVER a | ||
| 238 | + # literal-string escape character - regardless of sql_mode. Any byte that | ||
| 239 | + # would need escaping (0x27 quote, 0x5C backslash, etc.) triggers | ||
| 240 | + # "unterminated quoted string" once the client-escaped sequence is | ||
| 241 | + # misread by openGauss. This was verified independent of sql_mode | ||
| 242 | + # (reproduced identically with global NO_BACKSLASH_ESCAPES set via | ||
| 243 | + # init_command in new_conn()). | ||
| 244 | + # | ||
| 245 | + # Workaround: bind the hex-encoded *string* via %s (str parameters are | ||
| 246 | + # correctly escaped by PyMySQL - see tests 10/11) and let UNHEX()/HEX() | ||
| 247 | + # do the binary conversion server-side, avoiding the client-side bytes | ||
| 248 | + # escaping bug entirely while still exercising real parameter binding. | ||
| 249 | + conn = new_conn() | ||
| 250 | + cur = conn.cursor() | ||
| 251 | + cur.execute("DROP TABLE IF EXISTS t_blob") | ||
| 252 | + cur.execute("CREATE TABLE t_blob (id INT PRIMARY KEY, data BLOB)") | ||
| 253 | + empty = b"" | ||
| 254 | + cur.execute("INSERT INTO t_blob VALUES (1, UNHEX(%s))", (empty.hex(),)) | ||
| 255 | + binary = bytes([0x00, 0x01, 0x02, 0x7F, 0xFF]) | ||
| 256 | + cur.execute("INSERT INTO t_blob VALUES (2, UNHEX(%s))", (binary.hex(),)) | ||
| 257 | + big = bytes(range(256)) * 4 | ||
| 258 | + cur.execute("INSERT INTO t_blob VALUES (3, UNHEX(%s))", (big.hex(),)) | ||
| 259 | + conn.commit() | ||
| 260 | + # Verify exact byte content, not just length | ||
| 261 | + cur.execute("SELECT id, HEX(data) FROM t_blob ORDER BY id") | ||
| 262 | + rows = cur.fetchall() | ||
| 263 | + got_empty = bytes.fromhex(rows[0][1]) if rows[0][1] else b"" | ||
| 264 | + got_binary = bytes.fromhex(rows[1][1]) | ||
| 265 | + got_big = bytes.fromhex(rows[2][1]) | ||
| 266 | + assert got_empty == empty, f"got {got_empty!r} expected {empty!r}" | ||
| 267 | + assert got_binary == binary, f"got {got_binary!r} expected {binary!r}" | ||
| 268 | + assert got_big == big, f"got len={len(got_big)} expected len={len(big)}" | ||
| 269 | + cur.execute("DROP TABLE t_blob") | ||
| 270 | + conn.commit() | ||
| 271 | + conn.close() | ||
| 272 | + | ||
| 273 | + | ||
| 274 | +def test_large_text(): | ||
| 275 | + conn = new_conn() | ||
| 276 | + cur = conn.cursor() | ||
| 277 | + cur.execute("DROP TABLE IF EXISTS t_largetext") | ||
| 278 | + cur.execute("CREATE TABLE t_largetext (id INT PRIMARY KEY, content TEXT)") | ||
| 279 | + big = "A" * 1024 * 1024 # 1 MB | ||
| 280 | + cur.execute("INSERT INTO t_largetext VALUES (1, %s)", (big,)) | ||
| 281 | + conn.commit() | ||
| 282 | + cur.execute("SELECT LENGTH(content) FROM t_largetext WHERE id=1") | ||
| 283 | + assert cur.fetchone()[0] == 1024 * 1024 | ||
| 284 | + cur.execute("DROP TABLE t_largetext") | ||
| 285 | + conn.commit() | ||
| 286 | + conn.close() | ||
| 287 | + | ||
| 288 | + | ||
| 289 | +def test_datetime_types(): | ||
| 290 | + conn = new_conn() | ||
| 291 | + cur = conn.cursor() | ||
| 292 | + cur.execute("DROP TABLE IF EXISTS t_dt") | ||
| 293 | + cur.execute( | ||
| 294 | + "CREATE TABLE t_dt " | ||
| 295 | + "(id INT PRIMARY KEY, d DATE, t TIME, dt DATETIME, ts TIMESTAMP)" | ||
| 296 | + ) | ||
| 297 | + now = datetime.datetime(2026, 8, 12, 16, 0, 0) | ||
| 298 | + cur.execute( | ||
| 299 | + "INSERT INTO t_dt VALUES (%s,%s,%s,%s,%s)", | ||
| 300 | + (1, now.date(), now.time(), now, now), | ||
| 301 | + ) | ||
| 302 | + conn.commit() | ||
| 303 | + cur.execute("SELECT d, t, dt FROM t_dt WHERE id=1") | ||
| 304 | + row = cur.fetchone() | ||
| 305 | + assert isinstance(row[0], datetime.date) | ||
| 306 | + assert isinstance(row[1], datetime.timedelta) | ||
| 307 | + assert isinstance(row[2], datetime.datetime) | ||
| 308 | + cur.execute("DROP TABLE t_dt") | ||
| 309 | + conn.commit() | ||
| 310 | + conn.close() | ||
| 311 | + | ||
| 312 | + | ||
| 313 | +def test_decimal(): | ||
| 314 | + conn = new_conn() | ||
| 315 | + cur = conn.cursor() | ||
| 316 | + cur.execute("SELECT CAST('123.456' AS DECIMAL(10,3))") | ||
| 317 | + import decimal | ||
| 318 | + val = cur.fetchone()[0] | ||
| 319 | + assert val == decimal.Decimal("123.456") | ||
| 320 | + conn.close() | ||
| 321 | + | ||
| 322 | + | ||
| 323 | +def test_transaction_isolation(): | ||
| 324 | + conn1 = new_conn() | ||
| 325 | + conn2 = new_conn() | ||
| 326 | + cur1 = conn1.cursor() | ||
| 327 | + cur2 = conn2.cursor() | ||
| 328 | + cur1.execute("DROP TABLE IF EXISTS t_iso") | ||
| 329 | + cur1.execute("CREATE TABLE t_iso (id INT PRIMARY KEY, val INT)") | ||
| 330 | + cur1.execute("INSERT INTO t_iso VALUES (1, 100)") | ||
| 331 | + conn1.commit() | ||
| 332 | + | ||
| 333 | + conn1.autocommit(False) | ||
| 334 | + conn2.autocommit(False) | ||
| 335 | + cur1.execute("UPDATE t_iso SET val=200 WHERE id=1") | ||
| 336 | + cur2.execute("SELECT val FROM t_iso WHERE id=1") | ||
| 337 | + assert cur2.fetchone()[0] == 100 # not yet committed | ||
| 338 | + | ||
| 339 | + conn1.commit() | ||
| 340 | + cur2.execute("SELECT val FROM t_iso WHERE id=1") | ||
| 341 | + assert cur2.fetchone()[0] == 200 # now visible | ||
| 342 | + | ||
| 343 | + conn2.rollback() | ||
| 344 | + conn2.close() | ||
| 345 | + cur1.execute("DROP TABLE t_iso") | ||
| 346 | + conn1.commit() | ||
| 347 | + conn1.close() | ||
| 348 | + | ||
| 349 | + | ||
| 350 | +def test_reconnect_stability(): | ||
| 351 | + for i in range(10): | ||
| 352 | + conn = new_conn() | ||
| 353 | + cur = conn.cursor() | ||
| 354 | + cur.execute("SELECT %s", (i,)) | ||
| 355 | + assert cur.fetchone()[0] == i | ||
| 356 | + conn.close() | ||
| 357 | + | ||
| 358 | + | ||
| 359 | +def test_error_mapping(): | ||
| 360 | + conn = new_conn() | ||
| 361 | + cur = conn.cursor() | ||
| 362 | + results = {} | ||
| 363 | + | ||
| 364 | + # Table not found | ||
| 365 | + try: | ||
| 366 | + cur.execute("INSERT INTO nonexistent_table VALUES (1)") | ||
| 367 | + except pymysql.err.ProgrammingError: | ||
【问题】 不存在表错误后没有 rollback,重复键测试可能没有真正执行;代码还接受任意异常且没有结果断言。 【建议】 两个场景使用独立事务,错误后立即 rollback,并断言准确的 PyMySQL 异常类型。类似的问题都注意下 ![]() ![]() wofanzheng 3 天前 评论: 3 天前 评论: | |||
| 368 | + results["table_not_found"] = "ProgrammingError" | ||
| 369 | + except Exception as e: | ||
| 370 | + results["table_not_found"] = f"{type(e).__name__} (known limitation)" | ||
| 371 | + conn.rollback() # reset aborted-transaction state before next test | ||
| 372 | + | ||
| 373 | + # Duplicate primary key | ||
| 374 | + try: | ||
| 375 | + cur.execute("DROP TABLE IF EXISTS t_dup") | ||
| 376 | + cur.execute("CREATE TABLE t_dup (id INT PRIMARY KEY)") | ||
| 377 | + cur.execute("INSERT INTO t_dup VALUES (1)") | ||
| 378 | + cur.execute("INSERT INTO t_dup VALUES (1)") | ||
| 379 | + conn.commit() | ||
| 380 | + except pymysql.err.IntegrityError: | ||
| 381 | + results["dup_key"] = "IntegrityError" | ||
| 382 | + except Exception as e: | ||
| 383 | + results["dup_key"] = f"{type(e).__name__} (known limitation)" | ||
| 384 | + finally: | ||
| 385 | + conn.rollback() | ||
| 386 | + cur.execute("DROP TABLE IF EXISTS t_dup") | ||
| 387 | + conn.commit() | ||
| 388 | + | ||
| 389 | + conn.close() | ||
| 390 | + # Report known limitations without failing | ||
| 391 | + for k, v in results.items(): | ||
| 392 | + print(f" {k}: {v}") | ||
| 393 | + | ||
| 394 | + | ||
| 395 | +# ── Runner ───────────────────────────────────────────────────────────────────── | ||
| 396 | + | ||
| 397 | +TESTS = [ | ||
| 398 | + ("01 basic connect", test_basic_connect), | ||
| 399 | + ("02 CRUD", test_crud), | ||
| 400 | + ("03 parameterized & batch", test_parameterized_and_batch), | ||
| 401 | + ("04 autocommit flag", test_autocommit), | ||
| 402 | + ("05 commit / rollback", test_commit_rollback), | ||
| 403 | + ("06 DictCursor", test_dict_cursor), | ||
| 404 | + ("07 SSCursor", test_ss_cursor), | ||
| 405 | + ("08 fetchmany", test_fetchmany), | ||
| 406 | + ("09 Chinese & Unicode", test_chinese_unicode), | ||
| 407 | + ("10 special characters", test_special_characters), | ||
| 408 | + ("11 NO_BACKSLASH_ESCAPES", test_no_backslash_escapes), | ||
| 409 | + ("12 binary BLOB", test_binary_blob), | ||
| 410 | + ("13 large text (1 MB)", test_large_text), | ||
| 411 | + ("14 DATE/TIME/DATETIME", test_datetime_types), | ||
| 412 | + ("15 DECIMAL", test_decimal), | ||
| 413 | + ("16 transaction isolation", test_transaction_isolation), | ||
| 414 | + ("17 reconnect stability x10", test_reconnect_stability), | ||
| 415 | + ("18 error type mapping", test_error_mapping), | ||
| 416 | +] | ||
| 417 | + | ||
| 418 | +if __name__ == "__main__": | ||
| 419 | + passed = failed = 0 | ||
| 420 | + print(f"\nPyMySQL × openGauss(dolphin) compatibility test\n{'='*52}") | ||
| 421 | + for name, fn in TESTS: | ||
| 422 | + ok = run(name, fn) | ||
| 423 | + if ok: | ||
| 424 | + passed += 1 | ||
| 425 | + else: | ||
| 426 | + failed += 1 | ||
| 427 | + print(f"{'='*52}") | ||
| 428 | + print(f"Result: {passed} passed, {failed} failed / {len(TESTS)} total\n") | ||
| 429 | + raise SystemExit(1 if failed else 0) | ||


【问题】 new_conn() 未传 autocommit,PyMySQL 默认值为 False,但该文件93行断言为 True,正常连接时该用例会失败。 【建议】 将初始期望改为 False,或者显式使用 autocommit=None 验证服务端默认状态。