Skip to content
Draft
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ associated database, scheme / build tag, and scheme aliases:
| ChaiSQL | `chai` | `ci`, `genji`, `chaisql` | [github.com/chaisql/chai][d-chai] |
| Couchbase | `couchbase` | `n1`, `n1ql` | [github.com/couchbase/go_n1ql][d-couchbase] |
| Cznic QL | `ql` | `cznic`, `cznicql` | [modernc.org/ql][d-ql] |
| Dameng DM8 | `dm` / `dameng` | `dm8`, `dameng` | [github.com/godoes/gorm-dameng/dm8][d-dameng] |
| Databend | `databend` | `dd`, `bend` | [github.com/datafuselabs/databend-go][d-databend] |
| Databricks | `databricks` | `br`, `brick`, `bricks`, `databrick` | [github.com/databricks/databricks-sql-go][d-databricks] |
| DuckDB | `duckdb` | `dk`, `ddb`, `duck`, `file` | [github.com/duckdb/duckdb-go/v2][d-duckdb] <sup>[†][f-cgo]</sup> |
Expand Down Expand Up @@ -331,6 +332,7 @@ associated database, scheme / build tag, and scheme aliases:
[d-cosmos]: https://github.com/btnguyen2k/gocosmos
[d-couchbase]: https://github.com/couchbase/go_n1ql
[d-csvq]: https://github.com/mithrandie/csvq-driver
[d-dameng]: https://github.com/godoes/gorm-dameng
[d-databend]: https://github.com/datafuselabs/databend-go
[d-databricks]: https://github.com/databricks/databricks-sql-go
[d-duckdb]: https://github.com/duckdb/duckdb-go
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0001-use-pure-go-dameng-driver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Use a pure Go Dameng driver

Dameng support will use the MIT-licensed `database/sql` driver from `github.com/godoes/gorm-dameng/dm8` instead of DBX's Java/JDBC agent. The initial target is DM8 and the driver belongs to usql's `most` build group. This keeps usql a single binary and gives Dameng the same commands, permissions, metadata operations, output formats, and named-connection workflow as MySQL; a Dameng adapter will translate its DSN and metadata behavior internally so callers do not need a separate invocation model. PostgreSQL-style `sslmode=disable` is normalized to the driver's default non-SSL behavior, while unsupported secure `sslmode` values fail explicitly instead of silently weakening transport security.

The upstream contribution guide normally requires adding a scheme to `dburl`. This fork instead registers `dm`, `dm8`, and `dameng` at runtime so it remains buildable and installable from one repository while upstream `dburl` has no Dameng scheme. The README generator carries the same local exception. If upstream `dburl` later accepts Dameng, upgrade that dependency and remove both fork-local registrations.
207 changes: 207 additions & 0 deletions drivers/dameng/dameng.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Package dameng defines and registers usql's Dameng DM8 driver.
//
// See: https://github.com/godoes/gorm-dameng
// Group: most
package dameng

import (
"context"
"fmt"
"io"
"net"
"net/url"
"regexp"
"strings"

_ "github.com/godoes/gorm-dameng/dm8" // DRIVER: dm
"github.com/xo/dburl"
"github.com/xo/usql/drivers"
"github.com/xo/usql/drivers/metadata"
)

const (
// defaultPort is the standard DM8 TCP port.
defaultPort = "5236"
// schemaQueryKey is the DM8 driver's default-schema property.
schemaQueryKey = "schema"
// sslModeQueryKey is the PostgreSQL-style compatibility option accepted by usql.
sslModeQueryKey = "sslmode"
)

// init registers the DM8 URL scheme and its usql behavior.
func init() {
// trailingSemicolonRE removes a final statement separator rejected by Oracle-compatible drivers.
trailingSemicolonRE := regexp.MustCompile(`;?\s*$`)
// endBlockRE identifies procedural blocks whose END separator must remain intact.
endBlockRE := regexp.MustCompile(`(?i)\send\s*;\s*$`)

// Register the fork-local URL contract before usql parses any dm, dm8, or dameng connection.
dburl.Register(dburl.Scheme{
Driver: "dm",
Generator: generateDSN,
Transport: dburl.TransportTCP,
Aliases: []string{"dameng", "dm8"},
})
// Register the usql behavior shared by every alias of the DM8 database/sql driver.
drivers.Register("dm", drivers.Driver{
AllowMultilineComments: true,
LowerColumnNames: true,
Version: version,
User: currentUser,
IsPasswordErr: isPasswordErr,
Process: func(_ *dburl.URL, prefix string, sqlstr string) (string, string, bool, error) {
// Preserve the semicolon only when the SQL ends with a procedural END block.
if !endBlockRE.MatchString(sqlstr) {
sqlstr = trailingSemicolonRE.ReplaceAllString(sqlstr, "")
}
// queryType and query indicate how usql must dispatch the normalized statement.
queryType, query := drivers.QueryExecType(prefix, sqlstr)
return queryType, sqlstr, query, nil
},
NewMetadataReader: newMetadataReader,
NewMetadataWriter: func(db drivers.DB, writer io.Writer, opts ...metadata.ReaderOption) metadata.Writer {
// reader applies the same DM8 overrides used by completion and direct metadata calls.
reader := newMetadataReader(db, opts...)
// NewDefaultWriter exposes the shared \d, \dm, and \dp output behavior over the composed reader.
return metadata.NewDefaultWriter(reader)(db, writer)
},
Copy: drivers.CopyWithInsert(func(position int) string {
// DM8 uses Oracle-style numbered placeholders for each copied value.
return fmt.Sprintf(":%d", position)
}),
})
}

// generateDSN converts usql's URL contract into the DSN expected by the DM8 Go driver.
func generateDSN(connectionURL *dburl.URL) (string, string, error) {
// query holds a mutable copy of the user-provided driver options.
query := connectionURL.Query()
// pathSchema treats the URL path as the caller's default schema.
pathSchema := strings.Trim(connectionURL.Path, "/")
// configuredSchema preserves an explicitly supplied native DM8 schema option.
configuredSchema := query.Get(schemaQueryKey)

// Reject nested paths because DM8 accepts one default schema, not a path hierarchy.
if strings.Contains(pathSchema, "/") {
return "", "", fmt.Errorf("dameng schema path must contain exactly one segment")
}
// Reject conflicting schema declarations instead of silently selecting one.
if pathSchema != "" && configuredSchema != "" && !strings.EqualFold(pathSchema, configuredSchema) {
return "", "", fmt.Errorf("dameng schema is specified by both path and query with different values")
}
// Translate the portable path form only when no native schema option already exists.
if pathSchema != "" && configuredSchema == "" {
query.Set(schemaQueryKey, pathSchema)
}

// sslMode is the normalized compatibility value from existing dbhub-style DSNs.
sslMode := strings.ToLower(strings.TrimSpace(query.Get(sslModeQueryKey)))
// Accept only the explicitly agreed compatibility mode; secure modes require native DM8 parameters.
switch sslMode {
// An omitted compatibility option leaves native DM8 SSL parameters untouched.
case "":
// The dbhub-style disabled mode maps to the DM8 driver's non-SSL default.
case "disable":
query.Del(sslModeQueryKey)
// Other PostgreSQL-style modes cannot be translated without weakening their meaning.
default:
return "", "", fmt.Errorf("unsupported dameng sslmode %q; use sslFilesPath, sslCertPath, and sslKeyPath", sslMode)
}

// host defaults to localhost for consistency with other dburl schemes.
host := connectionURL.Hostname()
// Supply the DM8 host default only when the caller omitted it.
if host == "" {
host = "localhost"
}
// port defaults to DM8's standard listener port.
port := connectionURL.Port()
// Supply the standard port because the Go driver requires a host:port pair.
if port == "" {
port = defaultPort
}

// username and password are rebuilt with an explicit password separator for interactive prompting.
username, password := "", ""
// Read credentials only when the parsed URL includes user information.
if connectionURL.User != nil {
username = connectionURL.User.Username()
password, _ = connectionURL.User.Password()
}
// Reject credential delimiters that the DM8 driver's non-URL parser cannot represent safely.
if strings.ContainsAny(username, ":?") || strings.Contains(password, "?") {
return "", "", fmt.Errorf("dameng username cannot contain ':' or '?', and password cannot contain '?'")
}
// rawQuery preserves decoded native DM8 parameter values such as filesystem paths.
rawQuery, err := encodeDriverQuery(query)
// Return validation failures before constructing a DSN that the driver would misparse.
if err != nil {
return "", "", err
}
// driverDSN is the path-free string understood by github.com/godoes/gorm-dameng/dm8.
driverDSN := "dm://" + username + ":" + password + "@" + net.JoinHostPort(host, port)
// Append native options only when at least one option remains after compatibility translation.
if rawQuery != "" {
driverDSN += "?" + rawQuery
}
return driverDSN, "", nil
}

// encodeDriverQuery serializes standard URL query values for the DM8 driver's raw parser.
func encodeDriverQuery(query url.Values) (string, error) {
// Inspect every key and value because the DM8 driver splits options without URL decoding.
for key, values := range query {
// Reject key delimiters that would change the driver's option boundaries.
if strings.ContainsAny(key, "&=?") {
return "", fmt.Errorf("dameng option name %q cannot contain '&', '=', or '?'", key)
}
// Inspect every repeated value associated with the current native option.
for _, value := range values {
// Reject value delimiters that cannot be represented by the driver's query grammar.
if strings.ContainsAny(value, "&?") {
return "", fmt.Errorf("dameng option %q cannot contain '&' or '?'", key)
}
}
}
// rawQuery decodes the standard encoder's escapes after it supplies stable key ordering.
rawQuery, err := url.QueryUnescape(query.Encode())
// QueryUnescape should only fail for malformed escapes introduced outside url.Values.
if err != nil {
return "", fmt.Errorf("invalid dameng options: %w", err)
}
return rawQuery, nil
}

// version returns the DM8 server banner shown by usql after connecting.
func version(ctx context.Context, db drivers.DB) (string, error) {
// banner receives the first DM8 version banner exposed by the system view.
var banner string
// Return the query error so usql reports an unusable connection accurately.
if err := db.QueryRowContext(ctx, `SELECT BANNER FROM SYS.V$VERSION WHERE ROWNUM = 1`).Scan(&banner); err != nil {
return "", err
}
return banner, nil
}

// currentUser returns the authenticated DM8 account name.
func currentUser(ctx context.Context, db drivers.DB) (string, error) {
// username receives the account reported by DM8's Oracle-compatible USER expression.
var username string
// Return the query error so prompts never display a guessed identity.
if err := db.QueryRowContext(ctx, `SELECT USER FROM DUAL`).Scan(&username); err != nil {
return "", err
}
return username, nil
}

// isPasswordErr reports whether reconnecting with an interactively supplied password may succeed.
func isPasswordErr(err error) bool {
// message normalizes English and Chinese DM8 authentication errors for matching.
message := strings.ToLower(err.Error())
return strings.Contains(message, "password") ||
strings.Contains(message, "authentication") ||
strings.Contains(message, "login") ||
strings.Contains(message, "密码") ||
strings.Contains(message, "口令") ||
strings.Contains(message, "用户名")
}
149 changes: 149 additions & 0 deletions drivers/dameng/dameng_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package dameng

import (
"reflect"
"strings"
"testing"

"github.com/godoes/gorm-dameng/dm8"
"github.com/xo/dburl"
)

// TestGenerateDSN verifies the external compatibility contract and native DM8 output.
func TestGenerateDSN(t *testing.T) {
// tests cover the supported path, aliases, defaults, native SSL options, and rejected ambiguity.
tests := []struct {
// name identifies the compatibility scenario in test output.
name string
// input is the portable connection URL supplied to usql.
input string
// wantDSN is the native string passed to the DM8 driver.
wantDSN string
// wantSSLFilesPath checks the value parsed by the DM8 driver itself.
wantSSLFilesPath string
// wantErr is the expected validation error fragment.
wantErr string
}{
{
name: "path schema and disabled SSL",
input: "dm://SYSDBA:pwd@127.0.0.1:5236/APPDB?sslmode=disable",
wantDSN: "dm://SYSDBA:pwd@127.0.0.1:5236?schema=APPDB",
},
{
name: "long alias and native SSL",
input: "dameng://SYSDBA:pwd@db.example:5236?schema=APPDB&sslFilesPath=%2Fcerts",
wantDSN: "dm://SYSDBA:pwd@db.example:5236?schema=APPDB&sslFilesPath=/certs",
wantSSLFilesPath: "/certs",
},
{
name: "dm8 alias",
input: "dm8://SYSDBA:pwd@localhost/APPDB",
wantDSN: "dm://SYSDBA:pwd@localhost:5236?schema=APPDB",
},
{
name: "default port and explicit empty password",
input: "dm://SYSDBA@localhost/APPDB",
wantDSN: "dm://SYSDBA:@localhost:5236?schema=APPDB",
},
{
name: "decoded password",
input: "dm://SYSDBA:p%40ss@localhost/APPDB",
wantDSN: "dm://SYSDBA:p@ss@localhost:5236?schema=APPDB",
},
{
name: "conflicting schema",
input: "dm://SYSDBA:pwd@localhost/ONE?schema=TWO",
wantErr: "both path and query",
},
{
name: "unsupported secure sslmode",
input: "dm://SYSDBA:pwd@localhost/APPDB?sslmode=verify-full",
wantErr: "unsupported dameng sslmode",
},
{
name: "nested schema path",
input: "dm://SYSDBA:pwd@localhost/ONE/TWO",
wantErr: "exactly one segment",
},
{
name: "IPv6 host",
input: "dm://SYSDBA:pwd@[::1]/APPDB",
wantDSN: "dm://SYSDBA:pwd@[::1]:5236?schema=APPDB",
},
{
name: "native option containing separator",
input: "dm://SYSDBA:pwd@localhost/APPDB?sslFilesPath=%2Fcerts%26backup",
wantErr: "cannot contain",
},
}

// Execute every compatibility case through dburl.Parse, matching real usql behavior.
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// parsedURL is the normalized connection definition returned to usql.
parsedURL, err := dburl.Parse(test.input)
// Error cases must fail with the agreed actionable message.
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("dburl.Parse() error = %v, want containing %q", err, test.wantErr)
}
return
}
// Successful cases must parse before their driver fields can be checked.
if err != nil {
t.Fatalf("dburl.Parse() unexpected error: %v", err)
}
// Every supported alias must resolve to the registered DM8 driver.
if parsedURL.Driver != "dm" {
t.Fatalf("Driver = %q, want %q", parsedURL.Driver, "dm")
}
// The generated DSN is the exact string passed to database/sql.
if parsedURL.DSN != test.wantDSN {
t.Fatalf("DSN = %q, want %q", parsedURL.DSN, test.wantDSN)
}
// Driver-level parsing is required only for native options whose escaping caused the regression.
if test.wantSSLFilesPath == "" {
return
}
// driverConnector is the DM8 driver's parsed representation of the generated DSN.
driverConnector, err := (&dm8.DmDriver{}).OpenConnector(parsedURL.DSN)
// A generated DSN must be accepted by the selected driver before inspecting its properties.
if err != nil {
t.Fatalf("OpenConnector() unexpected error: %v", err)
}
// connectorValue exposes the driver's parsed sslFilesPath for a black-box boundary assertion.
connectorValue := reflect.ValueOf(driverConnector).Elem()
// parsedSSLFilesPath is the native value that the driver will use to locate certificates.
parsedSSLFilesPath := connectorValue.FieldByName("sslFilesPath").String()
// The native path must remain decoded instead of containing URL escape bytes.
if parsedSSLFilesPath != test.wantSSLFilesPath {
t.Fatalf("sslFilesPath = %q, want %q", parsedSSLFilesPath, test.wantSSLFilesPath)
}
})
}
}

// TestIsPasswordErr verifies interactive password prompting for English and Chinese driver errors.
func TestIsPasswordErr(t *testing.T) {
// passwordError represents the localized authentication failure returned by DM8.
passwordError := &testError{message: "用户名或密码错误"}
// Authentication errors should allow usql to prompt once for a corrected password.
if !isPasswordErr(passwordError) {
t.Fatal("isPasswordErr() = false, want true")
}
// Network errors must not trigger a misleading password prompt.
if isPasswordErr(&testError{message: "网络通信异常"}) {
t.Fatal("isPasswordErr() = true for network error, want false")
}
}

// testError supplies deterministic localized errors without requiring a live DM8 server.
type testError struct {
// message is the exact driver-facing error text returned by Error.
message string
}

// Error implements error for password classification tests.
func (err *testError) Error() string {
return err.message
}
Loading