diff --git a/docs/test-results/2026-09-26-type-connection-matrix.md b/docs/test-results/2026-09-26-type-connection-matrix.md index c2e8a3a..43ff2ff 100644 --- a/docs/test-results/2026-09-26-type-connection-matrix.md +++ b/docs/test-results/2026-09-26-type-connection-matrix.md @@ -61,3 +61,7 @@ ## 后续修复:本地时区时间戳绑定 `TIMESTAMP WITH LOCAL TIME ZONE` 列原先拒绝带时区的 Python `datetime`,返回 6015 日期时间格式错误。原因是 Python 默认格式把 `+05:30` 等偏移量紧贴时间,而该列要求偏移量前有空格。现在只在绑定 Python 带时区时间对象时规范化格式;正负偏移量与跨日输入均通过。Python 3.10 在 UTF-8 和 GB18030 官方 ARM DM8 实例上的完整真实库回归各为 **168 passed、0 skipped**,另各有 2 个非真实库用例未选入。 + +## 后续修复:区间子类型 + +桥接层原先只识别 `DAY TO SECOND` 和 `YEAR TO MONTH` 两种区间列;真实 DM8 中其余 11 种区间限定形式均被读成普通字符串。现在按 DPI 类型码识别全部 13 种形式。`DAY`、`HOUR`、`MINUTE`、`SECOND` 及其组合读为 `datetime.timedelta`,`YEAR`、`MONTH` 和 `YEAR TO MONTH` 保留 `dmPython.YEAR_MONTH_INTERVAL` 描述类型;部分子类型的正负 `timedelta` 写入也验证通过。Python 3.10 在 UTF-8 和 GB18030 官方 ARM DM8 实例上的完整真实库回归各为 **183 passed、0 skipped**,另各有 2 个非真实库用例未选入。 diff --git a/dpi_bridge/dpi_stmt.go b/dpi_bridge/dpi_stmt.go index b59096a..5be5dae 100644 --- a/dpi_bridge/dpi_stmt.go +++ b/dpi_bridge/dpi_stmt.go @@ -710,8 +710,30 @@ func mapGoTypeToDPI(ct *sql.ColumnType) (sqlType int16, precision uint64, scale return DSQL_TIMESTAMP, 26, 6, 26 case "TIMESTAMP WITH TIME ZONE", "DATETIME WITH TIME ZONE": return DSQL_TIMESTAMP_TZ, 34, 6, 34 + case "INTERVAL DAY": + return DSQL_INTERVAL_DAY, 0, 0, 64 + case "INTERVAL HOUR": + return DSQL_INTERVAL_HOUR, 0, 0, 64 + case "INTERVAL MINUTE": + return DSQL_INTERVAL_MINUTE, 0, 0, 64 + case "INTERVAL SECOND": + return DSQL_INTERVAL_SECOND, 0, 6, 64 + case "INTERVAL DAY TO HOUR": + return DSQL_INTERVAL_DAY_TO_HOUR, 0, 0, 64 + case "INTERVAL DAY TO MINUTE": + return DSQL_INTERVAL_DAY_TO_MINUTE, 0, 0, 64 case "INTERVAL DAY TO SECOND": return DSQL_INTERVAL_DAY_TO_SECOND, 0, 6, 64 + case "INTERVAL HOUR TO MINUTE": + return DSQL_INTERVAL_HOUR_TO_MINUTE, 0, 0, 64 + case "INTERVAL HOUR TO SECOND": + return DSQL_INTERVAL_HOUR_TO_SECOND, 0, 6, 64 + case "INTERVAL MINUTE TO SECOND": + return DSQL_INTERVAL_MINUTE_TO_SECOND, 0, 6, 64 + case "INTERVAL YEAR": + return DSQL_INTERVAL_YEAR, 0, 0, 64 + case "INTERVAL MONTH": + return DSQL_INTERVAL_MONTH, 0, 0, 64 case "INTERVAL YEAR TO MONTH": return DSQL_INTERVAL_YEAR_TO_MONTH, 0, 0, 64 case "BIT", "BOOL", "BOOLEAN": diff --git a/dpi_bridge/types.go b/dpi_bridge/types.go index 7c5c551..bd71a2c 100644 --- a/dpi_bridge/types.go +++ b/dpi_bridge/types.go @@ -197,6 +197,21 @@ const ( DSQL_INTERVAL_DAY_TO_SECOND = 109 ) +// Remaining DPI interval subtype codes (DPI.h, base 100). +const ( + DSQL_INTERVAL_YEAR = 100 + DSQL_INTERVAL_MONTH = 101 + DSQL_INTERVAL_DAY = 102 + DSQL_INTERVAL_HOUR = 103 + DSQL_INTERVAL_MINUTE = 104 + DSQL_INTERVAL_SECOND = 105 + DSQL_INTERVAL_DAY_TO_HOUR = 107 + DSQL_INTERVAL_DAY_TO_MINUTE = 108 + DSQL_INTERVAL_HOUR_TO_MINUTE = 110 + DSQL_INTERVAL_HOUR_TO_SECOND = 111 + DSQL_INTERVAL_MINUTE_TO_SECOND = 112 +) + // Environment/Connection attributes (from DPIext.h) const ( DSQL_ATTR_LOCAL_CODE = 12345 diff --git a/tests/integration/test_p1_type_matrix.py b/tests/integration/test_p1_type_matrix.py index 6d6dfa3..2724a7e 100644 --- a/tests/integration/test_p1_type_matrix.py +++ b/tests/integration/test_p1_type_matrix.py @@ -308,3 +308,98 @@ def test_year_month_interval_text_roundtrip(conn, table_name_factory, drop_table drop_table(cur, table) conn.commit() cur.close() + + +@pytest.mark.parametrize( + ("qualifier", "literal", "expected"), + [ + ("DAY(9)", "12", dt.timedelta(days=12)), + ("HOUR(9)", "27", dt.timedelta(hours=27)), + ("MINUTE(9)", "1600", dt.timedelta(minutes=1600)), + ("SECOND(9,6)", "123.123456", dt.timedelta(seconds=123, microseconds=123456)), + ("DAY(9) TO HOUR", "12 03", dt.timedelta(days=12, hours=3)), + ("DAY(9) TO MINUTE", "12 03:04", dt.timedelta(days=12, hours=3, minutes=4)), + ("HOUR(9) TO MINUTE", "27:04", dt.timedelta(hours=27, minutes=4)), + ( + "HOUR(9) TO SECOND(6)", + "27:04:05.123456", + dt.timedelta(hours=27, minutes=4, seconds=5, microseconds=123456), + ), + ( + "MINUTE(9) TO SECOND(6)", + "1600:05.123456", + dt.timedelta(minutes=1600, seconds=5, microseconds=123456), + ), + ], +) +def test_day_second_interval_qualifiers_return_timedelta( + conn, table_name_factory, drop_table, qualifier, literal, expected +): + table = table_name_factory("DMPY_INTERVAL_PART") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v INTERVAL {qualifier})") + cur.execute(f"INSERT INTO {table} VALUES (INTERVAL '{literal}' {qualifier})") + conn.commit() + cur.execute(f"SELECT v FROM {table}") + assert cur.description[0][1] is dmPython.INTERVAL + assert cur.fetchone() == (expected,) + finally: + drop_table(cur, table) + conn.commit() + cur.close() + + +@pytest.mark.parametrize( + ("qualifier", "value"), + [ + ("DAY(9) TO HOUR", dt.timedelta(days=12, hours=3)), + ("HOUR(9) TO MINUTE", -dt.timedelta(hours=27, minutes=4)), + ( + "HOUR(9) TO SECOND(6)", + dt.timedelta(hours=27, minutes=4, seconds=5, microseconds=123456), + ), + ( + "MINUTE(9) TO SECOND(6)", + dt.timedelta(minutes=1600, seconds=5, microseconds=123456), + ), + ], +) +def test_day_second_interval_qualifiers_accept_timedelta( + conn, table_name_factory, drop_table, qualifier, value +): + table = table_name_factory("DMPY_INTERVAL_BIND_PART") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v INTERVAL {qualifier})") + cur.execute(f"INSERT INTO {table} VALUES (?)", (value,)) + conn.commit() + cur.execute(f"SELECT v FROM {table}") + assert cur.description[0][1] is dmPython.INTERVAL + assert cur.fetchone() == (value,) + finally: + drop_table(cur, table) + conn.commit() + cur.close() + + +@pytest.mark.parametrize( + ("qualifier", "literal"), + [("YEAR(9)", "123"), ("MONTH(9)", "15")], +) +def test_year_month_interval_qualifiers_keep_type( + conn, table_name_factory, drop_table, qualifier, literal +): + table = table_name_factory("DMPY_INTERVAL_YM_PART") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v INTERVAL {qualifier})") + cur.execute(f"INSERT INTO {table} VALUES (INTERVAL '{literal}' {qualifier})") + conn.commit() + cur.execute(f"SELECT v FROM {table}") + assert cur.description[0][1] is dmPython.YEAR_MONTH_INTERVAL + assert re.search(rf"'{literal.zfill(9)}'", cur.fetchone()[0]) + finally: + drop_table(cur, table) + conn.commit() + cur.close()