From 4923fb5192c66cb2bb33680cb9ab25c74e29dc33 Mon Sep 17 00:00:00 2001 From: he_sk Date: Sat, 26 Sep 2026 17:12:46 +0800 Subject: [PATCH] test: cover DM data types and connection options --- .github/workflows/integration-tests.yml | 2 + docs/ci.md | 2 + .../2026-09-26-type-connection-matrix.md | 30 +++++ dpi_bridge/dpi_fetch.go | 4 +- .../integration/test_p1_connection_matrix.py | 103 ++++++++++++++++++ tests/integration/test_p1_type_matrix.py | 75 +++++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 docs/test-results/2026-09-26-type-connection-matrix.md create mode 100644 tests/integration/test_p1_connection_matrix.py create mode 100644 tests/integration/test_p1_type_matrix.py diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 654d965..cb8224f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -69,6 +69,8 @@ jobs: - name: Run real-database regression timeout-minutes: 20 + env: + TZ: Asia/Shanghai run: | if [ "${{ github.event_name }}" = pull_request ]; then python -m pytest -q --require-dm tests/integration \ diff --git a/docs/ci.md b/docs/ci.md index fc11232..7102c6b 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -2,6 +2,8 @@ `Build macOS wheels` is the pull-request and main-branch gate. Its first job runs the driver on CPython 3.9–3.13, with a fresh DM8 database per version on GitHub-hosted ARM Linux runners. Each job pulls a [community ARM development image](https://hub.docker.com/r/yhl452493373/dm8) by immutable digest, creates a dedicated test account, and runs P0/P1 on pull requests or the complete real-database suite on main and tags. Missing database, failed tests, skipped tests, and an empty JUnit report fail the job. Each version uploads its own JUnit summary and Python/server-version record. The [official-image Orb comparison](test-results/2026-09-25-five-python-release-rehearsal.md) passed the complete suite on all five Python versions; GitHub-hosted runners received HTTP 403 from the vendor download URL. +The [data-type and connection-option matrix](test-results/2026-09-26-type-connection-matrix.md) adds 38 P1 cases. CI sets `TZ=Asia/Shanghai` during the real-database run so the `TIME` binding regression remains observable on UTC-hosted runners. The matrix records currently unverified option effects and the high-precision `DECIMAL` write defect. + After all five real-database jobs pass, five macOS ARM jobs build and install wheels for CPython 3.9–3.13. They use the existing `DPI_HEADERS_TAR_B64` repository secret, so fork pull requests run the real-database matrix but skip macOS wheel builds. The required `CI gate` check accepts that documented fork exception; it requires both real-database and wheel jobs for trusted branches. Headers and image archives are not committed or uploaded as artifacts. The standalone `Integration Tests` workflow also runs the full five-version suite nightly and can be started manually. [Five-version results and release rehearsal](test-results/2026-09-25-five-python-release-rehearsal.md) record the exact scope. No GitHub repository database credentials or self-hosted runner are needed. The CI image is supplied by a third party and is pinned to its tested digest; it is not an official Dameng distribution. The publisher lists its development license expiry as 2027-07-07. Monitor the nightly job, validate image updates locally, and replace the digest only after recording the server version and full regression result. diff --git a/docs/test-results/2026-09-26-type-connection-matrix.md b/docs/test-results/2026-09-26-type-connection-matrix.md new file mode 100644 index 0000000..34a1540 --- /dev/null +++ b/docs/test-results/2026-09-26-type-connection-matrix.md @@ -0,0 +1,30 @@ +# 数据类型与连接选项行为矩阵(2026-09-26) + +## 本次验证 + +在本机 OrbStack 的隔离达梦官方 ARM64 实例上,使用非管理员测试账号运行 `--require-dm -m requires_dm tests`。Python 3.9、3.10、3.11、3.12、3.13 均为 **104 passed、0 failed、0 errors、0 skipped**;每个版本另有 2 个非真实库用例不在本次选择中。结果由 JUnit 摘要的零跳过门槛复核。此前相同环境每版为 66 个真实库用例,本次新增 38 个 P1 用例。GitHub 托管的开发镜像需等本分支 CI 再次验证。 + +| 范围 | 已验证行为 | +| --- | --- | +| 整数 | `SMALLINT` 下界、`INTEGER` 上界、`BIGINT` 上界往返 | +| 数值 | `DECIMAL(18,6)`、`FLOAT`、`DOUBLE` 的有限值往返 | +| 文本 | `VARCHAR`/`NVARCHAR` 中文和 emoji;`CHAR`/`NCHAR` 定长补空格 | +| 二进制 | `BINARY` 补零、`VARBINARY` 包含 NUL 与非 ASCII 字节 | +| 日期时间 | 闰日 `DATE`、`TIME` 时分秒、带微秒 `TIMESTAMP` 往返 | +| 空值 | 整数、十进制、文本、二进制、日期、时间戳列的 `NULL` 往返 | +| 连接地址 | `server`、`host`、`dsn=host:port` 均可连接;`host` 与 `server` 同时传入会报错 | +| 连接行为 | 默认元组与 `DictCursor` 行形状、`schema`、`autoCommit` 开关及开启后无需显式提交的持久化、`txn_isolation` 读回 | +| 选项基础检查 | 非数字 `port` 报错;`connection_timeout`、`login_timeout`、`compress_msg`、`use_stmt_pool` 可建连并执行查询 | + +`DECIMAL`、`FLOAT`、`DOUBLE`、`TIME`、`TIMESTAMP` 在当前默认读取路径通常返回字符串。矩阵断言数值或时间内容,同时保留未来改善 Python 返回类型的空间。 + +## 已发现的问题 + +1. **高精度十进制参数丢失小数**:向 `DECIMAL(30,8)` 绑定 `Decimal('12345678901234567890.12345678')` 或相同文本后,数据库中实际值为 `12345678901234567890.00000000`;直接在 SQL 中 `CAST` 同一字面量能保留小数。这是写入路径的准确性问题,当前 104 项通过不能代表高精度 `DECIMAL` 安全。修复后应把该值加入 CI 的往返断言。 +2. **连接选项的生效尚未验证**:`connection_timeout=5`、`login_timeout=5` 和 `app_name='dmpython_matrix'` 建连后,当前属性读回分别为 `0`、`0` 和空字符串。现有用例对前两项只验证“可建连”,不声称超时配置生效。需要单独设计可控的网络故障与服务器元数据检查。 + +本次修复了 `datetime.time` 绑定在亚洲/上海本地时区下偏移五分钟的问题:桥接层曾以公元 0 年构造无时区的 `TIME`,触发历史时区偏移;改用现代锚定日期后,上述五版用例均验证 `23:59:58` 原样写入和读回。 + +## 下一轮边界 + +需要继续覆盖高精度/负数/指数形式十进制,带时区时间、区间、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离和超时/故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。 diff --git a/dpi_bridge/dpi_fetch.go b/dpi_bridge/dpi_fetch.go index ea3ea69..710cbbb 100644 --- a/dpi_bridge/dpi_fetch.go +++ b/dpi_bridge/dpi_fetch.go @@ -1127,7 +1127,9 @@ func extractBoundValue(bind bindParamInfo) interface{} { return time.Date(int(d.year), time.Month(d.month), int(d.day), 0, 0, 0, 0, time.Local) case DSQL_C_TIME: t := (*C.dpi_time_t)(bind.dataPtr) - return time.Date(0, 1, 1, int(t.hour), int(t.minute), int(t.second), 0, time.Local) + // Year zero can carry a historical local offset (for example +08:05 in + // Asia/Shanghai), shifting a timezone-free TIME value when encoded. + return time.Date(2000, 1, 1, int(t.hour), int(t.minute), int(t.second), 0, time.Local) case DSQL_C_NUMERIC: num := (*C.dpi_numeric_t)(bind.dataPtr) return numericToString(num) diff --git a/tests/integration/test_p1_connection_matrix.py b/tests/integration/test_p1_connection_matrix.py new file mode 100644 index 0000000..6d3f970 --- /dev/null +++ b/tests/integration/test_p1_connection_matrix.py @@ -0,0 +1,103 @@ +"""Common connection forms and options against a real DM database.""" + +from __future__ import annotations + +import pytest + +import dmPython + + +pytestmark = [pytest.mark.requires_dm, pytest.mark.p1_contract] + + +@pytest.mark.parametrize("address", ["server", "host", "dsn"]) +def test_connection_address_forms(conn_params, address): + params = {"user": conn_params["user"], "password": conn_params["password"]} + host = conn_params["server"] + port = conn_params["port"] + if address == "dsn": + params["dsn"] = f"{host}:{port}" + else: + params[address] = host + params["port"] = port + with dmPython.connect(**params) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + assert conn.dsn == f"{host}:{port}" + + +@pytest.mark.parametrize("cursorclass", [dmPython.TupleCursor, dmPython.DictCursor]) +def test_cursorclass_result_shape(conn_params, cursorclass): + with dmPython.connect(**conn_params, cursorclass=cursorclass) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1 AS PROBE") + row = cur.fetchone() + if cursorclass == dmPython.DictCursor: + assert row == {"PROBE": 1} + else: + assert row == (1,) + + +def test_schema_option_selects_test_schema(conn_params): + with dmPython.connect(**conn_params, schema=conn_params["user"]) as conn: + assert str(conn.current_schema).upper() == str(conn_params["user"]).upper() + + +@pytest.mark.parametrize("autocommit", [dmPython.DSQL_AUTOCOMMIT_OFF, dmPython.DSQL_AUTOCOMMIT_ON]) +def test_autocommit_connect_option(conn_params, autocommit): + with dmPython.connect(**conn_params, autoCommit=autocommit) as conn: + assert int(conn.autocommit) == int(autocommit) + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + + +def test_autocommit_on_persists_without_explicit_commit( + conn, conn_params, table_name_factory, drop_table +): + table = table_name_factory("DMPY_AUTOCOMMIT") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v INTEGER)") + conn.commit() + with dmPython.connect(**conn_params, autoCommit=dmPython.DSQL_AUTOCOMMIT_ON) as writer: + with writer.cursor() as writer_cur: + writer_cur.execute(f"INSERT INTO {table} VALUES (1)") + cur.execute(f"SELECT COUNT(*) FROM {table}") + assert cur.fetchone() == (1,) + finally: + drop_table(cur, table) + conn.commit() + cur.close() + + +def test_isolation_connect_option(conn_params): + with dmPython.connect(**conn_params, txn_isolation=dmPython.ISO_LEVEL_READ_COMMITTED) as conn: + assert int(conn.txn_isolation) == int(dmPython.ISO_LEVEL_READ_COMMITTED) + + +def test_host_and_server_cannot_both_be_set(conn_params): + with pytest.raises(dmPython.NotSupportedError, match="host or server"): + dmPython.connect(**conn_params, host=conn_params["server"]) + + +def test_port_rejects_non_numeric_value(conn_params): + with pytest.raises((ValueError, TypeError, dmPython.Error)): + dmPython.connect(**{**conn_params, "port": object()}) + + +@pytest.mark.parametrize( + "option", + [ + {"connection_timeout": 5}, + {"login_timeout": 5}, + {"compress_msg": 0}, + {"use_stmt_pool": 1}, + ], +) +def test_optional_setting_connects_and_queries(conn_params, option): + with dmPython.connect(**conn_params, **option) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) diff --git a/tests/integration/test_p1_type_matrix.py b/tests/integration/test_p1_type_matrix.py new file mode 100644 index 0000000..d48a5b7 --- /dev/null +++ b/tests/integration/test_p1_type_matrix.py @@ -0,0 +1,75 @@ +"""Common SQL type round trips against a real DM database.""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal + +import pytest + + +pytestmark = [pytest.mark.requires_dm, pytest.mark.p1_contract] + + +@pytest.mark.parametrize( + ("sql_type", "value", "expected"), + [ + ("SMALLINT", -32768, -32768), + ("INTEGER", 2147483647, 2147483647), + ("BIGINT", 9223372036854775807, 9223372036854775807), + ("DECIMAL(18, 6)", Decimal("12345.678901"), Decimal("12345.678901")), + ("FLOAT", 1.25, Decimal("1.25")), + ("DOUBLE", 1.25, Decimal("1.25")), + ("BIT", True, True), + ("VARCHAR(80)", "汉字🚀", "汉字🚀"), + ("CHAR(4)", "AB", "AB "), + ("NCHAR(8)", "中文", "中文 "), + ("NVARCHAR(32)", "中文🚀", "中文🚀"), + ("BINARY(4)", b"\x00\xff", b"\x00\xff\x00\x00"), + ("VARBINARY(16)", b"\x00\x01\xfe\xff", b"\x00\x01\xfe\xff"), + ("DATE", dt.date(2024, 2, 29), dt.date(2024, 2, 29)), + ("TIME", dt.time(23, 59, 58), "23:59:58"), + ( + "TIMESTAMP", + dt.datetime(2024, 2, 29, 23, 59, 58, 123456), + "2024-02-29 23:59:58.123456", + ), + ], +) +def test_scalar_type_roundtrip(conn, table_name_factory, drop_table, sql_type, value, expected): + table = table_name_factory("DMPY_TYPE") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v {sql_type})") + cur.execute(f"INSERT INTO {table} VALUES (?)", (value,)) + conn.commit() + cur.execute(f"SELECT v FROM {table}") + actual = cur.fetchone()[0] + if sql_type in {"DECIMAL(18, 6)", "FLOAT", "DOUBLE"}: + assert Decimal(str(actual)) == expected + elif sql_type == "TIME": + assert str(actual).split()[-1].split(".")[0] == expected + elif sql_type == "TIMESTAMP": + assert str(actual) == expected + else: + assert actual == expected + finally: + drop_table(cur, table) + conn.commit() + cur.close() + + +@pytest.mark.parametrize("sql_type", ["INTEGER", "DECIMAL(18, 6)", "VARCHAR(80)", "VARBINARY(16)", "DATE", "TIMESTAMP"]) +def test_typed_null_roundtrip(conn, table_name_factory, drop_table, sql_type): + table = table_name_factory("DMPY_NULL") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v {sql_type})") + cur.execute(f"INSERT INTO {table} VALUES (?)", (None,)) + conn.commit() + cur.execute(f"SELECT v FROM {table}") + assert cur.fetchone() == (None,) + finally: + drop_table(cur, table) + conn.commit() + cur.close()