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
6 changes: 5 additions & 1 deletion docs/test-results/2026-09-26-type-connection-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。
8 changes: 7 additions & 1 deletion dpi_bridge/dpi_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion dpi_bridge/dpi_stmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/h.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 6 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/zi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
44 changes: 29 additions & 15 deletions src/native/vDateTime.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
21 changes: 18 additions & 3 deletions src/native/var.c
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -1661,4 +1677,3 @@ dmVar_PutDataAftExec(

return vLong_PutData((dm_LongVar*)var, arrayPos);
}

64 changes: 64 additions & 0 deletions tests/integration/test_p1_type_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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()
Loading