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 09c8165..5f66644 100644 --- a/docs/test-results/2026-09-26-type-connection-matrix.md +++ b/docs/test-results/2026-09-26-type-connection-matrix.md @@ -28,6 +28,10 @@ `DECIMAL(30,8)` 参数原先经过默认精度的二进制浮点转换,使 `12345678901234567890.12345678` 的小数位归零。现在直接解析十进制字符,并覆盖 `Decimal`、文本、负数和科学计数法四种参数形式。本机官方 DM8 上 Python 3.10 的完整真实库回归为 **108 passed、0 failed、0 skipped**;尚需以 CI 结果验证托管镜像。 +## 后续修复:带时区时间 + +绑定带固定时区的 Python `datetime.time`、`datetime.datetime` 或带偏移量的文本时,保留偏移量;读取 `TIME WITH TIME ZONE` 和 `TIMESTAMP WITH TIME ZONE` 时也保留偏移量。回归以时间点相等为准,允许数据库把输入时区规范化为服务器时区。本机官方 DM8 上,Python 3.9、3.10、3.11、3.12、3.13 的完整真实库回归均为 **112 passed、0 failed**,另有 2 个非真实库用例未选入。 + ## 下一轮边界 -需要继续覆盖其他十进制边界、带时区时间、区间、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离和超时/故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。 +需要继续覆盖其他十进制边界、时区边界、区间、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离和超时/故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。 diff --git a/dpi_bridge/dpi_fetch.go b/dpi_bridge/dpi_fetch.go index 710cbbb..7702f98 100644 --- a/dpi_bridge/dpi_fetch.go +++ b/dpi_bridge/dpi_fetch.go @@ -515,7 +515,13 @@ func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) { switch cType { case DSQL_C_NCHAR, DSQL_C_CHAR, DSQL_C_WCHAR: - writeStringValue(val, bind) + if t, ok := val.(time.Time); ok && sqlType == DSQL_TIME_TZ { + writeStringValue(t.Format("15:04:05.000000 -07:00"), bind) + } else if t, ok := val.(time.Time); ok && sqlType == DSQL_TIMESTAMP_TZ { + writeStringValue(t.Format("2006-01-02 15:04:05.000000 -07:00"), bind) + } else { + writeStringValue(val, bind) + } case DSQL_C_SLONG: writeInt32Value(val, bind) case DSQL_C_ULONG: diff --git a/dpi_bridge/dpi_stmt.go b/dpi_bridge/dpi_stmt.go index 9cf7d2a..4768582 100644 --- a/dpi_bridge/dpi_stmt.go +++ b/dpi_bridge/dpi_stmt.go @@ -672,9 +672,11 @@ func mapGoTypeToDPI(ct *sql.ColumnType) (sqlType int16, precision uint64, scale return DSQL_DATE, 10, 0, 10 case "TIME": return DSQL_TIME, 8, 0, 8 + case "TIME WITH TIME ZONE": + return DSQL_TIME_TZ, 20, 6, 20 case "TIMESTAMP", "DATETIME": return DSQL_TIMESTAMP, 26, 6, 26 - case "TIMESTAMP WITH TIME ZONE": + case "TIMESTAMP WITH TIME ZONE", "DATETIME WITH TIME ZONE": return DSQL_TIMESTAMP_TZ, 34, 6, 34 case "BIT", "BOOL", "BOOLEAN": return DSQL_BIT, 1, 0, 1 diff --git a/dpi_bridge/third_party/chunanyong_dm/h.go b/dpi_bridge/third_party/chunanyong_dm/h.go index b29a049..f50dcdd 100644 --- a/dpi_bridge/third_party/chunanyong_dm/h.go +++ b/dpi_bridge/third_party/chunanyong_dm/h.go @@ -20,6 +20,33 @@ func encodeByString(x string, column column, conn DmConnection) ([]byte, error) return encode(dt, column, int(conn.dmConnector.localTimezone), int(conn.DbTimezone)) } +func parseTimeZoneString(value string, timestamp bool) (time.Time, bool) { + value = strings.TrimSpace(value) + base := "15:04:05" + if timestamp { + base = "2006-01-02 15:04:05" + if len(value) > 10 && value[10] == 'T' { + value = value[:10] + " " + value[11:] + } + } + for _, layout := range []string{ + base + ".999999999Z07:00", + base + ".999999999 Z07:00", + base + "Z07:00", + base + " Z07:00", + } { + parsed, err := time.Parse(layout, value) + if err == nil { + if !timestamp { + parsed = time.Date(2000, 1, 1, parsed.Hour(), parsed.Minute(), + parsed.Second(), parsed.Nanosecond(), parsed.Location()) + } + return parsed, true + } + } + return time.Time{}, false +} + func encodeByTime(x time.Time, column column, conn DmConnection) ([]byte, error) { dt := toDTFromTime(x) return encode(dt, column, int(conn.dmConnector.localTimezone), int(conn.DbTimezone)) diff --git a/dpi_bridge/third_party/chunanyong_dm/zi.go b/dpi_bridge/third_party/chunanyong_dm/zi.go index 5576799..b97c42c 100644 --- a/dpi_bridge/third_party/chunanyong_dm/zi.go +++ b/dpi_bridge/third_party/chunanyong_dm/zi.go @@ -469,6 +469,9 @@ func (G2DB g2db) fromString(val string, param parameter, conn *DmConnection) (in return encodeByString(val, param.column, *conn) case TIME_TZ: + if parsed, ok := parseTimeZoneString(val, false); ok { + return encodeByTime(parsed, param.column, *conn) + } dt, err := parse(val, conn.FormatTimeTZ, int(conn.OracleDateLanguage)) if err != nil { return nil, err @@ -480,6 +483,9 @@ func (G2DB g2db) fromString(val string, param parameter, conn *DmConnection) (in return encodeByString(val, param.column, *conn) case DATETIME_TZ, DATETIME2_TZ: + if parsed, ok := parseTimeZoneString(val, true); ok { + return encodeByTime(parsed, param.column, *conn) + } if conn.FormatTimestampTZ != "" { dt, err := parse(val, conn.FormatTimestampTZ, int(conn.OracleDateLanguage)) if err != nil { diff --git a/src/native/vDateTime.c b/src/native/vDateTime.c index b1320db..b4cac1d 100644 --- a/src/native/vDateTime.c +++ b/src/native/vDateTime.c @@ -244,13 +244,13 @@ dm_VarType vt_TimestampTZ = { (PreDefineProc) NULL, (PreFetchProc) NULL, (IsNullProc) NULL, - (SetValueProc)TimestampVar_SetValue, - (GetValueProc)TimestampVar_GetValue, + (SetValueProc)TZVar_SetValue, + (GetValueProc)TZVar_GetValue, (GetBufferSizeProc) NULL, - (BindObjectValueProc)TimestampVar_BindObjectValue, + (BindObjectValueProc)TZVar_BindObjectValue, &g_TimestampTZType, // Python type - DSQL_C_TIMESTAMP, // C type - sizeof(dpi_timestamp_t), // element length (default) + DSQL_C_NCHAR, // C type + 64, // element length (default) 0, // is character data 0, // is variable length 1, // can be copied @@ -525,23 +525,37 @@ TZVar_SetValue( PyObject* value // value to set ) { - dm_Buffer buffer; + dm_Buffer buffer; + PyObject* text = value; - // populate the buffer and confirm the maximum size is not exceeded - if (dmBuffer_FromObject(&buffer, value, var->environment->encoding) < 0) - return -1; + if (PyDateTime_Check(value) || PyTime_Check(value)) + { + text = PyObject_Str(value); + if (!text) + return -1; + } - if (buffer.size) + if (dmBuffer_FromObject(&buffer, text, var->environment->encoding) < 0) { - memcpy(var->data + var->bufferSize * pos, buffer.ptr, buffer.size); + if (text != value) + Py_DECREF(text); + return -1; } + if (text != value) + Py_DECREF(text); - // keep a copy of the string - var->indicator[pos] = buffer.size; - var->actualLength[pos] = buffer.size; + if (buffer.size > var->bufferSize) + { + dmBuffer_Clear(&buffer); + PyErr_SetString(PyExc_ValueError, "time zone value exceeds the binding buffer"); + return -1; + } + if (buffer.size) + memcpy(var->data + var->bufferSize * pos, buffer.ptr, buffer.size); + var->indicator[pos] = buffer.size; + var->actualLength[pos] = buffer.size; dmBuffer_Clear(&buffer); - return 0; } diff --git a/src/native/var.c b/src/native/var.c index 20614b1..89c2cd6 100644 --- a/src/native/var.c +++ b/src/native/var.c @@ -648,10 +648,26 @@ dmVar_TypeByValue( return &vt_Date; if (PyDateTime_Check(value)) - return &vt_Timestamp; + { + PyObject* offset = PyObject_CallMethod(value, "utcoffset", NULL); + int aware; + if (!offset) + return NULL; + aware = offset != Py_None; + Py_DECREF(offset); + return aware ? &vt_TimestampTZ : &vt_Timestamp; + } if (PyTime_Check(value)) - return &vt_Time; + { + PyObject* offset = PyObject_CallMethod(value, "utcoffset", NULL); + int aware; + if (!offset) + return NULL; + aware = offset != Py_None; + Py_DECREF(offset); + return aware ? &vt_TimeTZ : &vt_Time; + } //��bigint�⣬����ȫӳ�䵽vt_Integer if (PyLong_Check(value)) @@ -1661,4 +1677,3 @@ dmVar_PutDataAftExec( return vLong_PutData((dm_LongVar*)var, arrayPos); } - diff --git a/tests/integration/test_p1_type_matrix.py b/tests/integration/test_p1_type_matrix.py index 12c4032..41863d5 100644 --- a/tests/integration/test_p1_type_matrix.py +++ b/tests/integration/test_p1_type_matrix.py @@ -10,6 +10,11 @@ pytestmark = [pytest.mark.requires_dm, pytest.mark.p1_contract] +_TZ_PLUS_0530 = dt.timezone(dt.timedelta(hours=5, minutes=30)) +_TZ_MINUS_0400 = dt.timezone(-dt.timedelta(hours=4)) +_AWARE_TIME = dt.time(12, 34, 56, 123456, tzinfo=_TZ_PLUS_0530) +_AWARE_TIMESTAMP = dt.datetime(2024, 2, 29, 12, 34, 56, 123456, tzinfo=_TZ_PLUS_0530) + @pytest.mark.parametrize( ("sql_type", "value", "expected"), @@ -100,3 +105,62 @@ def test_high_precision_decimal_parameter_preserves_fraction( drop_table(cur, table) conn.commit() cur.close() + + +@pytest.mark.parametrize( + ("sql_type", "value", "expected", "parse"), + [ + ( + "TIME(6) WITH TIME ZONE", + _AWARE_TIME, + _AWARE_TIME, + dt.time.fromisoformat, + ), + ( + "TIMESTAMP WITH TIME ZONE", + _AWARE_TIMESTAMP, + _AWARE_TIMESTAMP, + dt.datetime.fromisoformat, + ), + ( + "TIME(6) WITH TIME ZONE", + "12:34:56.123456 -04:00", + dt.time(12, 34, 56, 123456, tzinfo=_TZ_MINUS_0400), + dt.time.fromisoformat, + ), + ( + "TIMESTAMP WITH TIME ZONE", + "2024-02-29 12:34:56.123456 -04:00", + dt.datetime(2024, 2, 29, 12, 34, 56, 123456, tzinfo=_TZ_MINUS_0400), + dt.datetime.fromisoformat, + ), + ], + ids=["aware-time", "aware-timestamp", "text-time", "text-timestamp"], +) +def test_timezone_time_roundtrip_preserves_instant( + conn, table_name_factory, drop_table, sql_type, value, expected, parse +): + table = table_name_factory("DMPY_TZ") + 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, CAST(v AS VARCHAR(80)) FROM {table}") + direct, cast = cur.fetchone() + for actual in (direct, cast): + normalized = str(actual).replace(" +", "+").replace(" -", "-") + parsed = parse(normalized) + if isinstance(expected, dt.time): + assert parsed.utcoffset() is not None + assert ( + dt.datetime.combine(dt.date(2024, 1, 1), parsed) - parsed.utcoffset() + ).time() == ( + dt.datetime.combine(dt.date(2024, 1, 1), expected) - expected.utcoffset() + ).time() + else: + assert parsed == expected + finally: + drop_table(cur, table) + conn.commit() + cur.close()