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
10 changes: 10 additions & 0 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ jobs:
with:
python-version: "3.11"

- uses: actions/setup-go@v5
with:
go-version: "1.21"
cache-dependency-path: dpi_bridge/go.sum

- name: Test Go bridge and patched driver
run: |
(cd dpi_bridge && go test ./...)
(cd dpi_bridge/third_party/chunanyong_dm && go test ./...)

- name: Install lint dependencies
run: |
python -m pip install --upgrade pip
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dmPython.connect(

- `host` 与 `server` 互斥(只允许设置一个)。
- `user` 支持 `user/password@server:port[/schema][?catalog=...]` 形式。
- `login_timeout` 以秒为单位,限制首次建连握手;`connection_timeout` 以秒为单位,传给底层 TCP 拨号超时。两者均不限制 SQL 执行时间。
- 常量参数建议使用模块常量(如 `DSQL_AUTOCOMMIT_ON`、`ISO_LEVEL_READ_COMMITTED`)。

### 模块函数
Expand Down
10 changes: 8 additions & 2 deletions docs/test-results/2026-09-26-type-connection-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

## 已发现的问题

1. **连接选项的生效尚未验证**:`connection_timeout=5`、`login_timeout=5` 和 `app_name='dmpython_matrix'` 建连后,当前属性读回分别为 `0`、`0` 和空字符串。现有用例对前两项只验证“可建连”,不声称超时配置生效。需要单独设计可控的网络故障与服务器元数据检查。
1. **连接选项旧问题**:此前 `connection_timeout=5`、`login_timeout=5` 和 `app_name='dmpython_matrix'` 建连后,属性读回分别为 `0`、`0` 和空字符串;`login_timeout=1` 遇到无响应服务端时,4 秒内仍无法返回。本轮已修复并加入行为回归。测试账号没有 `SYS.V$SESSIONS` 查询权限,尚未从服务端会话视图独立核实 `app_name`。

本次修复了 `datetime.time` 绑定在亚洲/上海本地时区下偏移五分钟的问题:桥接层曾以公元 0 年构造无时区的 `TIME`,触发历史时区偏移;改用现代锚定日期后,上述五版用例均验证 `23:59:58` 原样写入和读回。

Expand All @@ -32,6 +32,12 @@

绑定带固定时区的 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 个非真实库用例未选入。

## 后续修复:区间与连接超时

`INTERVAL DAY TO SECOND` 现可与 Python `datetime.timedelta` 往返,包括负数、微秒、零值和负 10 万天;读取时描述类型为 `dmPython.INTERVAL`。`INTERVAL YEAR TO MONTH` 的文本参数与读取类型 `dmPython.YEAR_MONTH_INTERVAL` 也已验证,两个区间类型的 `NULL` 往返通过。旧实现对大负区间读取发生 32 位整数溢出,本轮已修复。

`login_timeout` 现限制完整建连握手:本机假服务端接受 TCP 后不回复,设为 1 秒会在约 1 秒内报错。`connection_timeout` 以秒传递给底层 TCP 拨号选项,属性读回与输入一致;`app_name` 会传给底层驱动并可读回。本机官方 DM8 上,Python 3.9、3.10、3.11、3.12、3.13 的完整真实库回归均为 **121 passed、0 failed**,另有 2 个非真实库用例未选入。

## 下一轮边界

需要继续覆盖其他十进制边界、时区边界、区间、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离和超时/故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。
需要继续覆盖其他十进制边界、时区边界、其他区间限定形式、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离、拨号超时和故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。
64 changes: 51 additions & 13 deletions dpi_bridge/dpi_conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ import (
"database/sql"
"database/sql/driver"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"unsafe"

dm "gitee.com/chunanyong/dm"
Expand All @@ -48,14 +50,15 @@ type connHandle struct {
tx driver.Tx // active transaction (nil if none)

// Connection parameters (set before login)
host string
port int
user string
password string
schema string
autocommit bool
host string
port int
user string
password string
schema string
autocommit bool
loginTimeout int
connTimeout int
appName string
txnIsolation int

// Post-login info
Expand Down Expand Up @@ -128,11 +131,19 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd
conn.autocommit = (intVal != 0)
// If already connected, apply autocommit
if conn.conn != nil {
conn.conn.Exec("SET TRANSACTION AUTOCOMMIT " + map[bool]string{true: "ON", false: "OFF"}[conn.autocommit], nil)
conn.conn.Exec("SET TRANSACTION AUTOCOMMIT "+map[bool]string{true: "ON", false: "OFF"}[conn.autocommit], nil)
}
case DSQL_ATTR_LOGIN_TIMEOUT:
if intVal < 0 {
conn.lastErr = &diagInfo{errorCode: -1, message: "login timeout must be non-negative"}
return DSQL_ERROR
}
conn.loginTimeout = intVal
case DSQL_ATTR_CONNECTION_TIMEOUT:
if intVal < 0 {
conn.lastErr = &diagInfo{errorCode: -1, message: "connection timeout must be non-negative"}
return DSQL_ERROR
}
conn.connTimeout = intVal
case DSQL_ATTR_TXN_ISOLATION:
conn.txnIsolation = intVal
Expand All @@ -154,8 +165,15 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd
} else {
conn.schema = C.GoString((*C.char)(val))
}
case DSQL_ATTR_APP_NAME,
DSQL_ATTR_SSL_PATH, DSQL_ATTR_SSL_PWD,
case DSQL_ATTR_APP_NAME:
if val == nil {
conn.appName = ""
} else if valLen > 0 {
conn.appName = C.GoStringN((*C.char)(val), C.int(valLen))
} else {
conn.appName = C.GoString((*C.char)(val))
}
case DSQL_ATTR_SSL_PATH, DSQL_ATTR_SSL_PWD,
DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN,
DSQL_ATTR_COMPRESS_MSG, DSQL_ATTR_USE_STMT_POOL,
DSQL_ATTR_MPP_LOGIN, DSQL_ATTR_RWSEPARATE,
Expand Down Expand Up @@ -213,6 +231,21 @@ func dpi_get_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, bufLen C.sd
if valLen != nil {
*valLen = 4
}
case DSQL_ATTR_LOGIN_TIMEOUT:
*(*C.sdint4)(val) = C.sdint4(conn.loginTimeout)
if valLen != nil {
*valLen = 4
}
case DSQL_ATTR_CONNECTION_TIMEOUT:
*(*C.sdint4)(val) = C.sdint4(conn.connTimeout)
if valLen != nil {
*valLen = 4
}
case DSQL_ATTR_APP_NAME:
n := cStringLen((*C.sdbyte)(val), int(bufLen), conn.appName)
if valLen != nil {
*valLen = C.sdint4(n)
}
case DSQL_ATTR_CONNECTION_DEAD:
dead := C.sdint4(0) // DSQL_CD_FALSE
if conn.conn == nil {
Expand Down Expand Up @@ -325,11 +358,11 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI
} else {
params = append(params, "autoCommit=false")
}
if conn.loginTimeout > 0 {
params = append(params, fmt.Sprintf("loginTimeout=%d", conn.loginTimeout))
}
if conn.connTimeout > 0 {
params = append(params, fmt.Sprintf("socketTimeout=%d", conn.connTimeout*1000))
params = append(params, fmt.Sprintf("socketTimeout=%d", conn.connTimeout))
}
if conn.appName != "" {
params = append(params, "appName="+url.QueryEscape(conn.appName))
}
if len(params) > 0 {
dsn += "?" + strings.Join(params, "&")
Expand All @@ -346,6 +379,11 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI

// Force a real connection
ctx := context.Background()
if conn.loginTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(conn.loginTimeout)*time.Second)
defer cancel()
}
rawConn, dbErr := db.Conn(ctx)
if dbErr != nil {
db.Close()
Expand Down
73 changes: 68 additions & 5 deletions dpi_bridge/dpi_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ typedef struct {
udbyte sign;
udbyte val[DPI_MAX_NUMERIC_LEN];
} dpi_numeric_t;

typedef struct {
int32_t interval_type;
int16_t interval_sign;
uint16_t reserved;
uint32_t day;
uint32_t hour;
uint32_t minute;
uint32_t second;
uint32_t fraction;
} dpi_interval_dt_t;
*/
import "C"
import (
Expand All @@ -63,6 +74,8 @@ import (
"strings"
"time"
"unsafe"

dm "gitee.com/chunanyong/dm"
)

//export dpi_number_columns
Expand Down Expand Up @@ -329,7 +342,10 @@ func dpi_fetch(hstmt C.dhstmt, rowNum *C.ulength) C.DPIRETURN {
rowBind.actLenPtr = (*C.slength)(unsafe.Pointer(uintptr(unsafe.Pointer(bind.actLenPtr)) + uintptr(fetched)*ei.indStride))
}
}
writeValueToBinding(row[colIdx-1], rowBind, stmt.columns[colIdx-1].sqlType)
if err := writeValueToBinding(row[colIdx-1], rowBind, stmt.columns[colIdx-1].sqlType); err != nil {
stmt.lastErr = diagFromError(err)
return DSQL_ERROR
}
}

stmt.fetchPos++
Expand Down Expand Up @@ -383,6 +399,8 @@ func cTypeSize(cType int16) uintptr {
return unsafe.Sizeof(C.dpi_time_t{})
case DSQL_C_NUMERIC:
return 19 // DPI_MAX_NUMERIC_LEN(16) + precision + scale + sign
case DSQL_C_INTERVAL_DAY_TO_SECOND:
return unsafe.Sizeof(C.dpi_interval_dt_t{})
default:
return 0 // variable-length (string, binary, etc.)
}
Expand Down Expand Up @@ -430,7 +448,10 @@ func dpi_get_data(hstmt C.dhstmt, icol C.udint2, ctype C.sdint2,
sqlType = stmt.columns[idx].sqlType
}

writeValueToBinding(rawVal, bind, sqlType)
if err := writeValueToBinding(rawVal, bind, sqlType); err != nil {
stmt.lastErr = diagFromError(err)
return DSQL_ERROR
}
return DSQL_SUCCESS
}

Expand Down Expand Up @@ -494,12 +515,12 @@ func dpi_row_count(hstmt C.dhstmt, rowNum *C.sdint8) C.DPIRETURN {
}

// writeValueToBinding writes a Go value into a C buffer according to the binding info.
func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) {
func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) error {
if val == nil {
if bind.indPtr != nil {
*bind.indPtr = C.slength(DSQL_NULL_DATA)
}
return
return nil
}

if bind.dataPtr == nil {
Expand All @@ -508,7 +529,7 @@ func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) {
if bind.indPtr != nil {
*bind.indPtr = C.slength(len(s))
}
return
return nil
}

cType := bind.cType
Expand Down Expand Up @@ -554,12 +575,45 @@ func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) {
writeTimeValue(val, bind)
case DSQL_C_NUMERIC:
writeNumericValue(val, bind)
case DSQL_C_INTERVAL_DAY_TO_SECOND:
return writeIntervalDaySecondValue(val, bind)
case DSQL_C_LOB_HANDLE:
writeLobHandleValue(val, bind, sqlType)
default:
// Default: treat as string
writeStringValue(val, bind)
}
return nil
}

func writeIntervalDaySecondValue(val interface{}, bind bindColInfo) error {
if bind.bufLen < int64(unsafe.Sizeof(C.dpi_interval_dt_t{})) {
return fmt.Errorf("interval binding buffer is too small")
}
value := fmt.Sprint(val)
interval, err := dm.NewDmIntervalDTByString(value)
if err != nil {
return fmt.Errorf("invalid day-second interval %q: %w", value, err)
}
data := (*C.dpi_interval_dt_t)(bind.dataPtr)
data.interval_type = 10 // DSQL_IS_DAY_TO_SECOND
data.interval_sign = 0
part := strings.TrimSpace(strings.TrimPrefix(strings.ToUpper(value), "INTERVAL"))
if strings.HasPrefix(part, "-") || strings.HasPrefix(part, "'-") {
data.interval_sign = 1
}
data.day = C.uint32_t(interval.GetDay())
data.hour = C.uint32_t(interval.GetHour())
data.minute = C.uint32_t(interval.GetMinute())
data.second = C.uint32_t(interval.GetSecond())
data.fraction = C.uint32_t(interval.GetMsec() * 1000)
if bind.indPtr != nil {
*bind.indPtr = C.slength(unsafe.Sizeof(C.dpi_interval_dt_t{}))
}
if bind.actLenPtr != nil {
*bind.actLenPtr = C.slength(unsafe.Sizeof(C.dpi_interval_dt_t{}))
}
return nil
}

func writeStringValue(val interface{}, bind bindColInfo) {
Expand Down Expand Up @@ -1139,6 +1193,15 @@ func extractBoundValue(bind bindParamInfo) interface{} {
case DSQL_C_NUMERIC:
num := (*C.dpi_numeric_t)(bind.dataPtr)
return numericToString(num)
case DSQL_C_INTERVAL_DAY_TO_SECOND:
interval := (*C.dpi_interval_dt_t)(bind.dataPtr)
sign := ""
if interval.interval_sign != 0 {
sign = "-"
}
return fmt.Sprintf("INTERVAL '%s%d %02d:%02d:%02d.%06d' DAY(9) TO SECOND(6)",
sign, interval.day, interval.hour, interval.minute,
interval.second, interval.fraction/1000)
case DSQL_C_LOB_HANDLE:
hlob := *(*C.dhloblctr)(bind.dataPtr)
if hlob == nil {
Expand Down
4 changes: 4 additions & 0 deletions dpi_bridge/dpi_stmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,10 @@ 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 TO SECOND":
return DSQL_INTERVAL_DAY_TO_SECOND, 0, 6, 64
case "INTERVAL YEAR TO MONTH":
return DSQL_INTERVAL_YEAR_TO_MONTH, 0, 0, 64
case "BIT", "BOOL", "BOOLEAN":
return DSQL_BIT, 1, 0, 1
case "BINARY":
Expand Down
22 changes: 22 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@
- `test_clob_unicode_problem_patterns_length_contract`
- `test_clob_unicode_problem_patterns_subprocess_no_crash`

## Patch: initial connection timeout through endpoint groups

- Files: `a.go`, `n.go`, `x.go`, `y.go`, `m.go`
- Problem: ordinary host connections are wrapped in an endpoint group. That path
replaced the caller's context with `context.Background()`, so `login_timeout`
could not stop an unresponsive handshake.
- Fix: pass the original context through endpoint selection and dialing, apply
its deadline to the socket during handshake, then clear the deadline once the
connection is established. Endpoint retries and wait intervals also respect
cancellation. Reconnects retain a fresh background context.
- Regression: `test_login_timeout_interrupts_unresponsive_handshake` uses a
local TCP listener that accepts a connection but never replies.

## Patch: application name query value

- File: `n.go`
- Decode the escaped `appName` query value so spaces, `&`, and `+` reach the
driver as one application name. Other existing DSN properties retain their
previous parsing behavior.
- Regression: `test_connection_timeout_options_are_reported` connects with an
application name containing `&` and `+`.

## Rollback

- Remove `replace gitee.com/chunanyong/dm => ./third_party/chunanyong_dm` in `dpi_bridge/go.mod`.
Expand Down
12 changes: 9 additions & 3 deletions dpi_bridge/third_party/chunanyong_dm/a.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ func dm_build_426(dm_build_427 context.Context, dm_build_428 *DmConnection) (*dm
if dm_build_432 {
dm_build_429, dm_build_430 = dm_build_431(dm_build_427, dm_build_428.dmConnector.host+":"+strconv.Itoa(int(dm_build_428.dmConnector.port)))
} else {
dm_build_429, dm_build_430 = dm_build_434(dm_build_428.dmConnector.host+":"+strconv.Itoa(int(dm_build_428.dmConnector.port)), time.Duration(dm_build_428.dmConnector.socketTimeout)*time.Second)
dm_build_429, dm_build_430 = dm_build_434(dm_build_427, dm_build_428.dmConnector.host+":"+strconv.Itoa(int(dm_build_428.dmConnector.port)), time.Duration(dm_build_428.dmConnector.socketTimeout)*time.Second)
}
if dm_build_430 != nil {
return nil, dm_build_430
}
if deadline, ok := dm_build_427.Deadline(); ok {
if err := dm_build_429.SetDeadline(deadline); err != nil {
dm_build_429.Close()
return nil, err
}
}

dm_build_433 := dm_build_414{}
dm_build_433.dm_build_415 = dm_build_429
Expand All @@ -67,8 +73,8 @@ func dm_build_426(dm_build_427 context.Context, dm_build_428 *DmConnection) (*dm
return &dm_build_433, nil
}

func dm_build_434(dm_build_435 string, dm_build_436 time.Duration) (net.Conn, error) {
dm_build_437, dm_build_438 := net.DialTimeout("tcp", dm_build_435, dm_build_436)
func dm_build_434(ctx context.Context, dm_build_435 string, dm_build_436 time.Duration) (net.Conn, error) {
dm_build_437, dm_build_438 := (&net.Dialer{Timeout: dm_build_436}).DialContext(ctx, "tcp", dm_build_435)
if dm_build_438 != nil {
return &net.TCPConn{}, ECGO_COMMUNITION_ERROR.addDetail("\tdial address: " + dm_build_435).throw()
}
Expand Down
2 changes: 1 addition & 1 deletion dpi_bridge/third_party/chunanyong_dm/m.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ func (dc *DmConnection) reconnect() error {

var newConn *DmConnection
if dc.dmConnector.group != nil {
if newConn, err = dc.dmConnector.group.connect(dc.dmConnector); err != nil {
if newConn, err = dc.dmConnector.group.connect(dc.dmConnector, context.Background()); err != nil {
return err
}
} else {
Expand Down
Loading
Loading