Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 2 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions docs/test-results/2026-09-26-type-connection-matrix.md
Original file line number Diff line number Diff line change
@@ -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 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。
4 changes: 3 additions & 1 deletion dpi_bridge/dpi_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions tests/integration/test_p1_connection_matrix.py
Original file line number Diff line number Diff line change
@@ -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,)
75 changes: 75 additions & 0 deletions tests/integration/test_p1_type_matrix.py
Original file line number Diff line number Diff line change
@@ -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()
Loading