mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-02 20:49:48 +08:00
✨ feat(iris): 新增 InterSystems IRIS 数据源支持
- 后端新增 IRIS 连接、查询、DDL、索引元数据和 DataGrid 编辑能力 - 接入 optional driver-agent、构建标签、revision 生成和变更检测流程 - 前端新增 IRIS 连接入口、方言映射、能力配置和图标展示 - 修复 IRIS 主键识别、事务开启错误处理和驱动连接关闭问题 - 补充后端、前端和构建脚本相关回归测试 Refs #408
This commit is contained in:
@@ -20,7 +20,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
if !isOceanBaseOracleProtocol(config) {
|
||||
runConfig.Database = name
|
||||
}
|
||||
case "mysql", "mariadb", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
|
||||
case "mysql", "mariadb", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "iris", "intersystems", "intersystemsiris", "inter-systems", "inter-systems-iris", "mongodb", "tdengine", "clickhouse":
|
||||
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
|
||||
runConfig.Database = name
|
||||
case "dameng":
|
||||
@@ -68,6 +68,16 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
||||
}
|
||||
}
|
||||
|
||||
if dbType == "iris" {
|
||||
schema, table := db.SplitSQLQualifiedName(rawTable)
|
||||
if schema != "" && table != "" {
|
||||
return schema, table
|
||||
}
|
||||
if table != "" {
|
||||
return "", table
|
||||
}
|
||||
}
|
||||
|
||||
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
|
||||
schema := strings.TrimSpace(parts[0])
|
||||
table := strings.TrimSpace(parts[1])
|
||||
|
||||
@@ -90,6 +90,43 @@ func TestNormalizeRunConfig_StarRocksUsesDatabaseFromTree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRunConfig_IRISUsesNamespaceFromTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runConfig := normalizeRunConfig(connection.ConnectionConfig{
|
||||
Type: "iris",
|
||||
Database: "USER",
|
||||
}, "APP")
|
||||
|
||||
if runConfig.Database != "APP" {
|
||||
t.Fatalf("expected IRIS namespace from tree, got %q", runConfig.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_IRISDoesNotTreatNamespaceAsSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{
|
||||
Type: "iris",
|
||||
}, "USER", "Person")
|
||||
|
||||
if schema != "" || table != "Person" {
|
||||
t.Fatalf("expected IRIS pure table to omit schema, got %q.%q", schema, table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_IRISSplitsQualifiedTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{
|
||||
Type: "iris",
|
||||
}, "USER", `"Sample.Schema"."Person.Table"`)
|
||||
|
||||
if schema != "Sample.Schema" || table != "Person.Table" {
|
||||
t.Fatalf("expected IRIS qualified table split, got %q.%q", schema, table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ func defaultPortByType(driverType string) int {
|
||||
return 9000
|
||||
case "highgo":
|
||||
return 5866
|
||||
case "iris":
|
||||
return 1972
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||
if dbType == "kingbase8" || dbType == "kingbasees" || dbType == "kingbasev8" {
|
||||
return "kingbase"
|
||||
}
|
||||
if dbType == "intersystems" || dbType == "intersystemsiris" || dbType == "inter-systems" || dbType == "inter-systems-iris" {
|
||||
return "iris"
|
||||
}
|
||||
if dbType == "oceanbase" && isOceanBaseOracleProtocol(config) {
|
||||
return "oracle"
|
||||
}
|
||||
@@ -194,6 +197,8 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||
return "highgo"
|
||||
case "vastbase":
|
||||
return "vastbase"
|
||||
case "iris", "intersystems", "intersystemsiris", "inter-systems", "inter-systems-iris":
|
||||
return "iris"
|
||||
case "oceanbase":
|
||||
return "oceanbase"
|
||||
}
|
||||
@@ -209,6 +214,8 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||
return "highgo"
|
||||
case strings.Contains(driver, "vastbase"):
|
||||
return "vastbase"
|
||||
case strings.Contains(driver, "iris"), strings.Contains(driver, "intersystems"):
|
||||
return "iris"
|
||||
case strings.Contains(driver, "sqlite"):
|
||||
return "sqlite"
|
||||
case strings.Contains(driver, "sphinx"):
|
||||
@@ -253,6 +260,16 @@ func normalizeSchemaAndTableByType(dbType string, dbName string, tableName strin
|
||||
}
|
||||
}
|
||||
|
||||
if dbType == "iris" {
|
||||
schema, table := db.SplitSQLQualifiedName(rawTable)
|
||||
if schema != "" && table != "" {
|
||||
return schema, table
|
||||
}
|
||||
if table != "" {
|
||||
return "", table
|
||||
}
|
||||
}
|
||||
|
||||
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
|
||||
schema := strings.TrimSpace(parts[0])
|
||||
table := strings.TrimSpace(parts[1])
|
||||
|
||||
@@ -72,6 +72,7 @@ func TestResolveDDLDBType_CustomDriverAlias(t *testing.T) {
|
||||
{name: "kingbase contains alias", driver: "kingbasees", want: "kingbase"},
|
||||
{name: "dm alias", driver: "dm8", want: "dameng"},
|
||||
{name: "sqlite alias", driver: "sqlite3", want: "sqlite"},
|
||||
{name: "iris alias", driver: "InterSystems IRIS", want: "iris"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -106,6 +107,14 @@ func TestResolveDDLDBType_KingbaseTypeAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDDLDBType_IRISTypeAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := resolveDDLDBType(connection.ConnectionConfig{Type: "InterSystemsIRIS"}); got != "iris" {
|
||||
t.Fatalf("expected InterSystemsIRIS type alias to resolve to iris, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTableByType_PGLikeQuotedQualifiedName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -346,6 +346,7 @@ const builtinDriverManifestJSON = `{
|
||||
"highgo": { "engine": "go", "version": "0.0.0-local", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" },
|
||||
"vastbase": { "engine": "go", "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/vastbase" },
|
||||
"opengauss": { "engine": "go", "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/opengauss" },
|
||||
"iris": { "engine": "go", "version": "0.2.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/iris" },
|
||||
"mongodb": { "engine": "go", "version": "2.5.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" },
|
||||
"tdengine": { "engine": "go", "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" },
|
||||
"clickhouse": { "engine": "go", "version": "2.43.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" }
|
||||
@@ -402,6 +403,7 @@ var latestDriverVersionMap = map[string]string{
|
||||
"highgo": "0.0.0-local",
|
||||
"vastbase": "1.11.2",
|
||||
"opengauss": "1.11.1",
|
||||
"iris": "0.2.1",
|
||||
"mongodb": "2.5.0",
|
||||
"tdengine": "3.7.8",
|
||||
"clickhouse": "2.43.1",
|
||||
@@ -424,6 +426,7 @@ var driverGoModulePathMap = map[string]string{
|
||||
"highgo": "github.com/highgo/pq-sm3",
|
||||
"vastbase": "github.com/lib/pq",
|
||||
"opengauss": "github.com/lib/pq",
|
||||
"iris": "github.com/caretdev/go-irisnative",
|
||||
"mongodb": "go.mongodb.org/mongo-driver/v2",
|
||||
"tdengine": "github.com/taosdata/driver-go/v3",
|
||||
"clickhouse": "github.com/ClickHouse/clickhouse-go/v2",
|
||||
@@ -1404,6 +1407,8 @@ func normalizeDriverType(driverType string) string {
|
||||
return "postgres"
|
||||
case "opengauss", "open_gauss", "open-gauss":
|
||||
return "opengauss"
|
||||
case "intersystems", "intersystemsiris", "inter-systems-iris", "inter-systems":
|
||||
return "iris"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
@@ -1485,6 +1490,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [
|
||||
buildOptionalGoDriverDefinition("highgo", "HighGo", packages),
|
||||
buildOptionalGoDriverDefinition("vastbase", "Vastbase", packages),
|
||||
buildOptionalGoDriverDefinition("opengauss", "OpenGauss", packages),
|
||||
buildOptionalGoDriverDefinition("iris", "InterSystems IRIS", packages),
|
||||
buildOptionalGoDriverDefinition("mongodb", "MongoDB", packages),
|
||||
buildOptionalGoDriverDefinition("tdengine", "TDengine", packages),
|
||||
buildOptionalGoDriverDefinition("clickhouse", "ClickHouse", packages),
|
||||
@@ -3804,6 +3810,8 @@ func optionalDriverBuildTag(driverType string, selectedVersion string) (string,
|
||||
return "gonavi_vastbase_driver", nil
|
||||
case "opengauss":
|
||||
return "gonavi_opengauss_driver", nil
|
||||
case "iris":
|
||||
return "gonavi_iris_driver", nil
|
||||
case "mongodb":
|
||||
if resolveMongoDriverMajorFromVersion(selectedVersion) == 1 {
|
||||
return "gonavi_mongodb_driver_v1", nil
|
||||
|
||||
@@ -138,6 +138,7 @@ func optionalDriverAgentRevisionTestDrivers(t *testing.T) []string {
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
"iris",
|
||||
"mongodb",
|
||||
"tdengine",
|
||||
"clickhouse",
|
||||
|
||||
@@ -212,6 +212,36 @@ func TestBuiltinActivatePinnedVersionDoesNotRestrictBundleFallback(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIRISDriverDefinitionUsesOptionalAgent(t *testing.T) {
|
||||
definition, ok := resolveDriverDefinition("iris")
|
||||
if !ok {
|
||||
t.Fatal("expected iris driver definition")
|
||||
}
|
||||
if definition.Name != "InterSystems IRIS" {
|
||||
t.Fatalf("unexpected iris driver name: %q", definition.Name)
|
||||
}
|
||||
if driverGoModulePathMap["iris"] != "github.com/caretdev/go-irisnative" {
|
||||
t.Fatalf("unexpected iris go module path: %q", driverGoModulePathMap["iris"])
|
||||
}
|
||||
if definition.PinnedVersion != "0.2.1" {
|
||||
t.Fatalf("unexpected iris definition pinned version: %q", definition.PinnedVersion)
|
||||
}
|
||||
if definition.DefaultDownloadURL != "builtin://activate/iris" {
|
||||
t.Fatalf("unexpected iris default download URL: %q", definition.DefaultDownloadURL)
|
||||
}
|
||||
if latestDriverVersionMap["iris"] != "0.2.1" {
|
||||
t.Fatalf("unexpected iris pinned version: %q", latestDriverVersionMap["iris"])
|
||||
}
|
||||
|
||||
tags, err := optionalDriverBuildTags("iris", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve iris build tags failed: %v", err)
|
||||
}
|
||||
if tags != "gonavi_iris_driver" {
|
||||
t.Fatalf("unexpected iris build tag: %q", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOptionalDriverInstallPlanMessagePrefersDirectThenBundle(t *testing.T) {
|
||||
message := buildOptionalDriverInstallPlanMessage("SQL Server", "1.9.6", false, false, false, false, 1, 2)
|
||||
if !strings.Contains(message, "先尝试 1 个预编译直链") {
|
||||
|
||||
@@ -1602,7 +1602,8 @@ func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testi
|
||||
if !strings.Contains(res.Message, "失败审计写入失败") {
|
||||
t.Fatalf("expected failed audit failure marker, got %q", res.Message)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(res.Message), "not a directory") {
|
||||
lowerMessage := strings.ToLower(res.Message)
|
||||
if !strings.Contains(lowerMessage, "not a directory") && !strings.Contains(lowerMessage, "system cannot find the path specified") {
|
||||
t.Fatalf("expected underlying audit failure detail in message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,21 @@ type CustomDB struct {
|
||||
}
|
||||
|
||||
func (c *CustomDB) Connect(config connection.ConnectionConfig) error {
|
||||
if config.Driver == "" || config.DSN == "" {
|
||||
driver := strings.TrimSpace(config.Driver)
|
||||
dsn := strings.TrimSpace(config.DSN)
|
||||
if driver == "" || dsn == "" {
|
||||
return fmt.Errorf("driver and dsn are required for custom connection")
|
||||
}
|
||||
|
||||
// Verify driver is registered (implicit check by sql.Open)
|
||||
// We might not need explicit check, sql.Open will fail or Ping will fail if driver not found.
|
||||
|
||||
db, err := sql.Open(config.Driver, config.DSN)
|
||||
db, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
return formatCustomDriverOpenError(driver, err)
|
||||
}
|
||||
c.conn = db
|
||||
c.driver = config.Driver
|
||||
c.driver = driver
|
||||
c.pingTimeout = getConnectTimeout(config)
|
||||
if err := c.Ping(); err != nil {
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
@@ -38,6 +40,27 @@ func (c *CustomDB) Connect(config connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatCustomDriverOpenError(driver string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unknown driver") {
|
||||
if isLikelySystemODBCDriverName(driver) {
|
||||
return fmt.Errorf("打开数据库连接失败:自定义连接不支持直接填写系统 ODBC/JDBC 驱动名 %q;请填写 GoNavi 已注册的 Go database/sql 驱动名。当前版本未注册通用 ODBC 驱动,因此暂不支持通过 %q 连接 InterSystems IRIS:%w", driver, driver, err)
|
||||
}
|
||||
return fmt.Errorf("打开数据库连接失败:自定义连接驱动 %q 未在 GoNavi 中注册;请填写已注册的 Go database/sql 驱动名,不能填写系统 ODBC/JDBC 驱动名:%w", driver, err)
|
||||
}
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
|
||||
func isLikelySystemODBCDriverName(driver string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(driver))
|
||||
return strings.Contains(normalized, "odbc") ||
|
||||
strings.Contains(normalized, "jdbc") ||
|
||||
strings.Contains(normalized, "intersystems") ||
|
||||
strings.Contains(normalized, "iris")
|
||||
}
|
||||
|
||||
func (c *CustomDB) Close() error {
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
|
||||
54
internal/db/custom_impl_test.go
Normal file
54
internal/db/custom_impl_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestCustomDBConnectReportsUnsupportedODBCDriverName(t *testing.T) {
|
||||
db := &CustomDB{}
|
||||
|
||||
err := db.Connect(connection.ConnectionConfig{
|
||||
Driver: "InterSystems IRIS ODBC35",
|
||||
DSN: "Driver={InterSystems IRIS ODBC35};Server=127.0.0.1;Port=1972;Database=USER;",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported ODBC driver error, got nil")
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, want := range []string{
|
||||
"ODBC/JDBC",
|
||||
"Go database/sql",
|
||||
"暂不支持",
|
||||
"InterSystems IRIS",
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected error to contain %q, got %q", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDBConnectReportsUnregisteredGoDriver(t *testing.T) {
|
||||
db := &CustomDB{}
|
||||
|
||||
err := db.Connect(connection.ConnectionConfig{
|
||||
Driver: "not-a-registered-go-driver",
|
||||
DSN: "demo",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unregistered Go driver error, got nil")
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, want := range []string{
|
||||
"未在 GoNavi 中注册",
|
||||
"Go database/sql",
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected error to contain %q, got %q", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,8 @@ func normalizeDatabaseType(dbType string) string {
|
||||
return "kingbase"
|
||||
case "opengauss", "open_gauss", "open-gauss":
|
||||
return "opengauss"
|
||||
case "intersystems", "intersystemsiris", "inter-systems-iris", "inter-systems":
|
||||
return "iris"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func registerOptionalDatabaseFactories() {
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("highgo"), "highgo")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("opengauss"), "opengauss", "open_gauss", "open-gauss")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("iris"), "iris", "intersystems")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
|
||||
|
||||
@@ -16,6 +16,7 @@ func registerOptionalDatabaseFactories() {
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("highgo"), "highgo")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("opengauss"), "opengauss", "open_gauss", "open-gauss")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("iris"), "iris", "intersystems")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
|
||||
|
||||
@@ -17,6 +17,7 @@ func init() {
|
||||
"highgo": "src-5a29a1d3685eb6b4",
|
||||
"vastbase": "src-e3cfef65512feb23",
|
||||
"opengauss": "src-58227ba3bc1ec894",
|
||||
"iris": "src-1b072c57af08bec4",
|
||||
"mongodb": "src-57fdd8bfebdcd46e",
|
||||
"tdengine": "src-939715f94df1ec9c",
|
||||
"clickhouse": "src-482d62ed565b3e69",
|
||||
|
||||
@@ -34,6 +34,7 @@ var optionalGoDrivers = map[string]struct{}{
|
||||
"highgo": {},
|
||||
"vastbase": {},
|
||||
"opengauss": {},
|
||||
"iris": {},
|
||||
"mongodb": {},
|
||||
"tdengine": {},
|
||||
"clickhouse": {},
|
||||
@@ -60,6 +61,8 @@ func normalizeRuntimeDriverType(driverType string) string {
|
||||
return "kingbase"
|
||||
case "opengauss", "open_gauss", "open-gauss":
|
||||
return "opengauss"
|
||||
case "intersystems", "intersystemsiris", "inter-systems-iris", "inter-systems":
|
||||
return "iris"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
@@ -101,6 +104,8 @@ func driverDisplayName(driverType string) string {
|
||||
return "Vastbase"
|
||||
case "opengauss":
|
||||
return "OpenGauss"
|
||||
case "iris":
|
||||
return "InterSystems IRIS"
|
||||
case "mongodb":
|
||||
return "MongoDB"
|
||||
case "tdengine":
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestNewCompatibleDriversAreOptionalAgentDrivers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
SetExternalDriverDownloadDirectory(tmpDir)
|
||||
|
||||
for _, driverType := range []string{"oceanbase", "opengauss", "open_gauss", "starrocks"} {
|
||||
for _, driverType := range []string{"oceanbase", "opengauss", "open_gauss", "starrocks", "iris", "intersystems"} {
|
||||
if IsBuiltinDriver(driverType) {
|
||||
t.Fatalf("%s 不应是免安装内置驱动", driverType)
|
||||
}
|
||||
|
||||
960
internal/db/iris_impl.go
Normal file
960
internal/db/iris_impl.go
Normal file
@@ -0,0 +1,960 @@
|
||||
//go:build gonavi_full_drivers || gonavi_iris_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
_ "github.com/caretdev/go-irisnative"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIRISPort = 1972
|
||||
defaultIRISNamespace = "USER"
|
||||
)
|
||||
|
||||
type IrisDB struct {
|
||||
conn *sql.DB
|
||||
pingTimeout time.Duration
|
||||
namespace string
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
type irisTableRef struct {
|
||||
Schema string
|
||||
Table string
|
||||
}
|
||||
|
||||
func normalizeIRISNamespace(namespace string) string {
|
||||
trimmed := strings.Trim(strings.TrimSpace(namespace), "/")
|
||||
if trimmed == "" {
|
||||
return defaultIRISNamespace
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func applyIRISURI(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
parsed, ok := parseConnectionURI(config.URI, "iris", "intersystems")
|
||||
if !ok || parsed == nil {
|
||||
return config
|
||||
}
|
||||
next := config
|
||||
if host := strings.TrimSpace(parsed.Hostname()); host != "" {
|
||||
next.Host = host
|
||||
}
|
||||
if portText := strings.TrimSpace(parsed.Port()); portText != "" {
|
||||
if port, err := strconv.Atoi(portText); err == nil && port > 0 {
|
||||
next.Port = port
|
||||
}
|
||||
}
|
||||
if parsed.User != nil {
|
||||
next.User = parsed.User.Username()
|
||||
if password, ok := parsed.User.Password(); ok {
|
||||
next.Password = password
|
||||
}
|
||||
}
|
||||
if namespace := strings.Trim(strings.TrimSpace(parsed.Path), "/"); namespace != "" {
|
||||
next.Database = namespace
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (i *IrisDB) getDSN(config connection.ConnectionConfig) string {
|
||||
namespace := normalizeIRISNamespace(config.Database)
|
||||
port := config.Port
|
||||
if port <= 0 {
|
||||
port = defaultIRISPort
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: "iris",
|
||||
Host: net.JoinHostPort(config.Host, strconv.Itoa(port)),
|
||||
Path: "/" + namespace,
|
||||
}
|
||||
u.User = url.UserPassword(config.User, config.Password)
|
||||
|
||||
q := url.Values{}
|
||||
mergeConnectionParamsFromConfig(q, config, "iris", "intersystems")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (i *IrisDB) Connect(config connection.ConnectionConfig) error {
|
||||
runConfig := applyIRISURI(config)
|
||||
if runConfig.Port <= 0 {
|
||||
runConfig.Port = defaultIRISPort
|
||||
}
|
||||
i.namespace = normalizeIRISNamespace(runConfig.Database)
|
||||
|
||||
cleanupOnFailure := true
|
||||
defer func() {
|
||||
if !cleanupOnFailure {
|
||||
return
|
||||
}
|
||||
if i.conn != nil {
|
||||
_ = i.conn.Close()
|
||||
i.conn = nil
|
||||
}
|
||||
if i.forwarder != nil {
|
||||
_ = i.forwarder.Close()
|
||||
i.forwarder = nil
|
||||
}
|
||||
}()
|
||||
|
||||
if runConfig.UseSSH {
|
||||
logger.Infof("InterSystems IRIS 使用 SSH 连接:地址=%s:%d 用户=%s", runConfig.Host, runConfig.Port, runConfig.User)
|
||||
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, runConfig.Host, runConfig.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||
}
|
||||
i.forwarder = forwarder
|
||||
|
||||
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析本地转发地址失败:%w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析本地端口失败:%w", err)
|
||||
}
|
||||
|
||||
runConfig.Host = host
|
||||
runConfig.Port = port
|
||||
runConfig.UseSSH = false
|
||||
logger.Infof("InterSystems IRIS 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||
}
|
||||
|
||||
db, err := sql.Open("iris", i.getDSN(runConfig))
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
i.conn = db
|
||||
i.pingTimeout = getConnectTimeout(runConfig)
|
||||
if err := i.Ping(); err != nil {
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
cleanupOnFailure = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) Close() error {
|
||||
if i.forwarder != nil {
|
||||
if err := i.forwarder.Close(); err != nil {
|
||||
logger.Warnf("关闭 InterSystems IRIS SSH 端口转发失败:%v", err)
|
||||
}
|
||||
i.forwarder = nil
|
||||
}
|
||||
if i.conn != nil {
|
||||
return i.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) Ping() error {
|
||||
if i.conn == nil {
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
timeout := i.pingTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
return i.conn.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (i *IrisDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
|
||||
if i.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := i.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (i *IrisDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
|
||||
if i.conn == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := i.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMultiRows(rows)
|
||||
}
|
||||
|
||||
func (i *IrisDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if i.conn == nil {
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := i.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (i *IrisDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if i.conn == nil {
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
rows, err := i.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (i *IrisDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if i.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := i.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (i *IrisDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
|
||||
return i.ExecContext(ctx, query)
|
||||
}
|
||||
|
||||
func (i *IrisDB) Exec(query string) (int64, error) {
|
||||
if i.conn == nil {
|
||||
return 0, fmt.Errorf("连接未打开")
|
||||
}
|
||||
res, err := i.conn.Exec(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetDatabases() ([]string, error) {
|
||||
namespace := strings.TrimSpace(i.namespace)
|
||||
if namespace != "" {
|
||||
return []string{namespace}, nil
|
||||
}
|
||||
data, _, err := i.Query(`SELECT DISTINCT TABLE_CATALOG FROM INFORMATION_SCHEMA.TABLES`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var namespaces []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, row := range data {
|
||||
name := strings.TrimSpace(rowString(row, "TABLE_CATALOG", "table_catalog"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
namespaces = append(namespaces, name)
|
||||
}
|
||||
sort.Strings(namespaces)
|
||||
return namespaces, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetTables(dbName string) ([]string, error) {
|
||||
data, _, err := i.Query(`SELECT * FROM INFORMATION_SCHEMA.TABLES`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tables []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, row := range data {
|
||||
tableType := strings.ToUpper(strings.TrimSpace(rowString(row, "TABLE_TYPE", "table_type")))
|
||||
if tableType != "" && tableType != "TABLE" && tableType != "BASE TABLE" {
|
||||
continue
|
||||
}
|
||||
schema := strings.TrimSpace(rowString(row, "TABLE_SCHEMA", "table_schema", "SCHEMA_NAME", "schema_name"))
|
||||
table := strings.TrimSpace(rowString(row, "TABLE_NAME", "table_name"))
|
||||
if table == "" || isIRISSystemSchema(schema) {
|
||||
continue
|
||||
}
|
||||
name := table
|
||||
if schema != "" {
|
||||
name = schema + "." + table
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
sort.Strings(tables)
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
ref, err := parseIRISTableRef(dbName, tableName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
columns, err := i.GetColumns(dbName, tableName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return "", fmt.Errorf("未找到表字段:%s", tableName)
|
||||
}
|
||||
indexes, idxErr := i.GetIndexes(dbName, tableName)
|
||||
if idxErr != nil {
|
||||
indexes = nil
|
||||
}
|
||||
return buildIRISCreateTableDDL(ref, columns, indexes), nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
ref, err := parseIRISTableRef(dbName, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _, err := i.Query(buildIRISInfoSchemaWhereQuery("INFORMATION_SCHEMA.COLUMNS", ref))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indexes, _ := i.GetIndexes(dbName, tableName)
|
||||
keyByColumn := irisColumnKeyMap(indexes)
|
||||
|
||||
columns := make([]connection.ColumnDefinition, 0, len(data))
|
||||
for _, row := range data {
|
||||
name := strings.TrimSpace(rowString(row, "COLUMN_NAME", "column_name"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := keyByColumn[name]
|
||||
if primary, ok := irisBoolFromRow(row, "PRIMARY_KEY", "primary_key"); ok && primary {
|
||||
key = "PRI"
|
||||
} else if key == "" {
|
||||
if unique, ok := irisBoolFromRow(row, "UNIQUE_COLUMN", "unique_column", "IS_UNIQUE", "is_unique", "UNIQUE", "unique"); ok && unique {
|
||||
key = "UNI"
|
||||
}
|
||||
}
|
||||
col := connection.ColumnDefinition{
|
||||
Name: name,
|
||||
Type: buildIRISColumnType(row),
|
||||
Nullable: normalizeIRISNullable(rowString(row, "IS_NULLABLE", "is_nullable")),
|
||||
Key: key,
|
||||
Extra: "",
|
||||
Comment: rowString(row, "DESCRIPTION", "description", "COMMENT", "comment"),
|
||||
}
|
||||
if rawDefault, ok := rowValue(row, "COLUMN_DEFAULT", "column_default"); ok && rawDefault != nil {
|
||||
def := strings.TrimSpace(fmt.Sprintf("%v", rawDefault))
|
||||
if def != "" {
|
||||
col.Default = &def
|
||||
}
|
||||
}
|
||||
columns = append(columns, col)
|
||||
}
|
||||
sort.SliceStable(columns, func(a, b int) bool {
|
||||
return rowOrdinal(data, columns[a].Name) < rowOrdinal(data, columns[b].Name)
|
||||
})
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
data, _, err := i.Query(`SELECT * FROM INFORMATION_SCHEMA.COLUMNS`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols := make([]connection.ColumnDefinitionWithTable, 0, len(data))
|
||||
for _, row := range data {
|
||||
schema := strings.TrimSpace(rowString(row, "TABLE_SCHEMA", "table_schema"))
|
||||
table := strings.TrimSpace(rowString(row, "TABLE_NAME", "table_name"))
|
||||
name := strings.TrimSpace(rowString(row, "COLUMN_NAME", "column_name"))
|
||||
if table == "" || name == "" || isIRISSystemSchema(schema) {
|
||||
continue
|
||||
}
|
||||
tableName := table
|
||||
if schema != "" {
|
||||
tableName = schema + "." + table
|
||||
}
|
||||
cols = append(cols, connection.ColumnDefinitionWithTable{
|
||||
TableName: tableName,
|
||||
Name: name,
|
||||
Type: buildIRISColumnType(row),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(cols, func(a, b int) bool {
|
||||
if cols[a].TableName == cols[b].TableName {
|
||||
return cols[a].Name < cols[b].Name
|
||||
}
|
||||
return cols[a].TableName < cols[b].TableName
|
||||
})
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
ref, err := parseIRISTableRef(dbName, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _, err := i.Query(buildIRISInfoSchemaWhereQuery("INFORMATION_SCHEMA.INDEXES", ref))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indexes := make([]connection.IndexDefinition, 0, len(data))
|
||||
for _, row := range data {
|
||||
name := strings.TrimSpace(rowString(row, "INDEX_NAME", "index_name", "KEY_NAME", "key_name", "CONSTRAINT_NAME", "constraint_name"))
|
||||
column := strings.TrimSpace(rowString(row, "COLUMN_NAME", "column_name"))
|
||||
primary, hasPrimaryFlag := irisBoolFromRow(row, "PRIMARY_KEY", "primary_key")
|
||||
if name == "" && hasPrimaryFlag && primary {
|
||||
name = "PRIMARY"
|
||||
}
|
||||
if name == "" || column == "" {
|
||||
continue
|
||||
}
|
||||
indexType := normalizeIRISIndexType(rowString(row, "INDEX_TYPE", "index_type", "TYPE", "type"))
|
||||
if hasPrimaryFlag && primary {
|
||||
indexType = "PRIMARY"
|
||||
}
|
||||
nonUnique := parseIRISNonUnique(row)
|
||||
indexes = append(indexes, connection.IndexDefinition{
|
||||
Name: name,
|
||||
ColumnName: column,
|
||||
NonUnique: nonUnique,
|
||||
SeqInIndex: parseIRISInt(rowValueAny(row, "ORDINAL_POSITION", "ordinal_position", "SEQ_IN_INDEX", "seq_in_index", "KEY_SEQ", "key_seq")),
|
||||
IndexType: indexType,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(indexes, func(a, b int) bool {
|
||||
if indexes[a].Name == indexes[b].Name {
|
||||
return indexes[a].SeqInIndex < indexes[b].SeqInIndex
|
||||
}
|
||||
return indexes[a].Name < indexes[b].Name
|
||||
})
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return []connection.ForeignKeyDefinition{}, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return []connection.TriggerDefinition{}, nil
|
||||
}
|
||||
|
||||
func (i *IrisDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if i.conn == nil {
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
tx, err := i.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, keys := range changes.Deletes {
|
||||
query, args, ok := buildIRISDeleteSQL(tableName, keys)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除失败:%v", err)
|
||||
}
|
||||
if err := requireSingleRowAffected(res, "删除"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, update := range changes.Updates {
|
||||
query, args, ok, err := buildIRISUpdateSQL(tableName, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新失败:%v", err)
|
||||
}
|
||||
if err := requireSingleRowAffected(res, "更新"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range changes.Inserts {
|
||||
query, args, ok := buildIRISInsertSQL(tableName, row)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("插入失败:%v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func buildIRISInfoSchemaWhereQuery(table string, ref irisTableRef) string {
|
||||
conditions := []string{fmt.Sprintf("TABLE_NAME = '%s'", irisSQLLiteral(ref.Table))}
|
||||
if ref.Schema != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("TABLE_SCHEMA = '%s'", irisSQLLiteral(ref.Schema)))
|
||||
}
|
||||
orderBy := ""
|
||||
switch strings.ToUpper(strings.TrimSpace(table)) {
|
||||
case "INFORMATION_SCHEMA.COLUMNS":
|
||||
orderBy = " ORDER BY ORDINAL_POSITION"
|
||||
case "INFORMATION_SCHEMA.INDEXES":
|
||||
orderBy = " ORDER BY INDEX_NAME, ORDINAL_POSITION"
|
||||
}
|
||||
return fmt.Sprintf("SELECT * FROM %s WHERE %s%s", table, strings.Join(conditions, " AND "), orderBy)
|
||||
}
|
||||
|
||||
func parseIRISTableRef(defaultSchema, raw string) (irisTableRef, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return irisTableRef{}, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
if schemaPart, tablePart, ok := splitIRISTablePath(text); ok {
|
||||
schema := cleanIRISIdentifier(schemaPart)
|
||||
table := cleanIRISIdentifier(tablePart)
|
||||
if table == "" {
|
||||
return irisTableRef{}, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
return irisTableRef{Schema: schema, Table: table}, nil
|
||||
}
|
||||
return irisTableRef{Schema: cleanIRISIdentifier(defaultSchema), Table: cleanIRISIdentifier(text)}, nil
|
||||
}
|
||||
|
||||
func splitIRISTablePath(raw string) (schemaPart, tablePart string, ok bool) {
|
||||
inQuote := false
|
||||
for idx := 0; idx < len(raw); idx++ {
|
||||
switch raw[idx] {
|
||||
case '"':
|
||||
if inQuote && idx+1 < len(raw) && raw[idx+1] == '"' {
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
inQuote = !inQuote
|
||||
case '.':
|
||||
if !inQuote {
|
||||
return raw[:idx], raw[idx+1:], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", raw, false
|
||||
}
|
||||
|
||||
func cleanIRISIdentifier(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
text = strings.Trim(text, `"`)
|
||||
return strings.ReplaceAll(text, `""`, `"`)
|
||||
}
|
||||
|
||||
func irisSQLLiteral(raw string) string {
|
||||
return strings.ReplaceAll(raw, "'", "''")
|
||||
}
|
||||
|
||||
func irisQuoteIdent(name string) string {
|
||||
text := cleanIRISIdentifier(name)
|
||||
text = strings.ReplaceAll(text, `"`, `""`)
|
||||
return `"` + text + `"`
|
||||
}
|
||||
|
||||
func irisQuoteTable(raw string) string {
|
||||
ref, err := parseIRISTableRef("", raw)
|
||||
if err != nil {
|
||||
return irisQuoteIdent(raw)
|
||||
}
|
||||
if ref.Schema != "" {
|
||||
return irisQuoteIdent(ref.Schema) + "." + irisQuoteIdent(ref.Table)
|
||||
}
|
||||
return irisQuoteIdent(ref.Table)
|
||||
}
|
||||
|
||||
func isIRISSystemSchema(schema string) bool {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(schema))
|
||||
return normalized == "INFORMATION_SCHEMA" ||
|
||||
strings.HasPrefix(normalized, "%") ||
|
||||
strings.HasPrefix(normalized, "SYS")
|
||||
}
|
||||
|
||||
func rowValue(row map[string]interface{}, keys ...string) (interface{}, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := row[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
for existing, value := range row {
|
||||
if strings.EqualFold(existing, key) {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func rowValueAny(row map[string]interface{}, keys ...string) interface{} {
|
||||
value, _ := rowValue(row, keys...)
|
||||
return value
|
||||
}
|
||||
|
||||
func rowString(row map[string]interface{}, keys ...string) string {
|
||||
value, ok := rowValue(row, keys...)
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
func parseIRISInt(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return n
|
||||
default:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(fmt.Sprintf("%v", value)))
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
func parseIRISBool(value interface{}) (bool, bool) {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case int:
|
||||
return v != 0, true
|
||||
case int64:
|
||||
return v != 0, true
|
||||
case float64:
|
||||
return v != 0, true
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "t", "yes", "y":
|
||||
return true, true
|
||||
case "0", "false", "f", "no", "n":
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func irisBoolFromRow(row map[string]interface{}, keys ...string) (bool, bool) {
|
||||
value, ok := rowValue(row, keys...)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return parseIRISBool(value)
|
||||
}
|
||||
|
||||
func parseIRISNonUnique(row map[string]interface{}) int {
|
||||
if primary, ok := irisBoolFromRow(row, "PRIMARY_KEY", "primary_key"); ok && primary {
|
||||
return 0
|
||||
}
|
||||
if value, ok := rowValue(row, "NON_UNIQUE", "non_unique"); ok {
|
||||
if enabled, ok := parseIRISBool(value); ok {
|
||||
if enabled {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
n := parseIRISInt(value)
|
||||
if n != 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if value, ok := rowValue(row, "IS_UNIQUE", "is_unique", "UNIQUE", "unique"); ok {
|
||||
if unique, ok := parseIRISBool(value); ok && unique {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if unique, ok := irisBoolFromRow(row, "UNIQUE_COLUMN", "unique_column"); ok && unique {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func normalizeIRISIndexType(raw string) string {
|
||||
text := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if text == "" {
|
||||
return "BTREE"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func normalizeIRISNullable(raw string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "NO", "N", "FALSE", "0":
|
||||
return "NO"
|
||||
default:
|
||||
return "YES"
|
||||
}
|
||||
}
|
||||
|
||||
func buildIRISColumnType(row map[string]interface{}) string {
|
||||
dataType := strings.TrimSpace(rowString(row, "DATA_TYPE", "data_type", "TYPE_NAME", "type_name"))
|
||||
if dataType == "" {
|
||||
dataType = "VARCHAR"
|
||||
}
|
||||
upper := strings.ToUpper(dataType)
|
||||
charLength := parseIRISInt(rowValueAny(row, "CHARACTER_MAXIMUM_LENGTH", "character_maximum_length", "CHARACTER_MAX_LENGTH", "character_max_length"))
|
||||
precision := parseIRISInt(rowValueAny(row, "NUMERIC_PRECISION", "numeric_precision"))
|
||||
scale := parseIRISInt(rowValueAny(row, "NUMERIC_SCALE", "numeric_scale"))
|
||||
if charLength > 0 && (strings.Contains(upper, "CHAR") || strings.Contains(upper, "VARCHAR")) && !strings.Contains(dataType, "(") {
|
||||
return fmt.Sprintf("%s(%d)", dataType, charLength)
|
||||
}
|
||||
if precision > 0 && (strings.Contains(upper, "NUMERIC") || strings.Contains(upper, "DECIMAL") || strings.Contains(upper, "NUMBER")) && !strings.Contains(dataType, "(") {
|
||||
if scale > 0 {
|
||||
return fmt.Sprintf("%s(%d,%d)", dataType, precision, scale)
|
||||
}
|
||||
return fmt.Sprintf("%s(%d)", dataType, precision)
|
||||
}
|
||||
return dataType
|
||||
}
|
||||
|
||||
func rowOrdinal(rows []map[string]interface{}, columnName string) int {
|
||||
for idx, row := range rows {
|
||||
if strings.EqualFold(rowString(row, "COLUMN_NAME", "column_name"), columnName) {
|
||||
ordinal := parseIRISInt(rowValueAny(row, "ORDINAL_POSITION", "ordinal_position"))
|
||||
if ordinal > 0 {
|
||||
return ordinal
|
||||
}
|
||||
return idx + 1
|
||||
}
|
||||
}
|
||||
return len(rows) + 1
|
||||
}
|
||||
|
||||
func irisColumnKeyMap(indexes []connection.IndexDefinition) map[string]string {
|
||||
result := map[string]string{}
|
||||
for _, idx := range indexes {
|
||||
column := strings.TrimSpace(idx.ColumnName)
|
||||
if column == "" {
|
||||
continue
|
||||
}
|
||||
if isIRISPrimaryIndex(idx) {
|
||||
result[column] = "PRI"
|
||||
continue
|
||||
}
|
||||
if idx.NonUnique == 0 && result[column] == "" {
|
||||
result[column] = "UNI"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isIRISPrimaryIndexName(name string) bool {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(name))
|
||||
return normalized == "PRIMARY" || normalized == "PRIMARYKEY" || normalized == "IDKEY"
|
||||
}
|
||||
|
||||
func isIRISPrimaryIndex(idx connection.IndexDefinition) bool {
|
||||
return isIRISPrimaryIndexName(idx.Name) || strings.EqualFold(strings.TrimSpace(idx.IndexType), "PRIMARY")
|
||||
}
|
||||
|
||||
func buildIRISCreateTableDDL(ref irisTableRef, columns []connection.ColumnDefinition, indexes []connection.IndexDefinition) string {
|
||||
qualified := irisQuoteIdent(ref.Table)
|
||||
if strings.TrimSpace(ref.Schema) != "" {
|
||||
qualified = irisQuoteIdent(ref.Schema) + "." + qualified
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(columns)+1)
|
||||
primaryColumns := irisPrimaryColumns(indexes)
|
||||
if len(primaryColumns) == 0 {
|
||||
primaryColumns = irisPrimaryColumnsFromColumns(columns)
|
||||
}
|
||||
for _, col := range columns {
|
||||
line := fmt.Sprintf(" %s %s", irisQuoteIdent(col.Name), strings.TrimSpace(col.Type))
|
||||
if col.Default != nil && strings.TrimSpace(*col.Default) != "" {
|
||||
line += " DEFAULT " + strings.TrimSpace(*col.Default)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
line += " NOT NULL"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(primaryColumns) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" PRIMARY KEY (%s)", irisQuoteIdentList(primaryColumns)))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("CREATE TABLE %s (\n%s\n);", qualified, strings.Join(lines, ",\n")))
|
||||
|
||||
for _, stmt := range buildIRISCreateIndexStatements(ref, indexes) {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(stmt)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func irisPrimaryColumns(indexes []connection.IndexDefinition) []string {
|
||||
for _, group := range groupIRISIndexes(indexes) {
|
||||
if group.Primary {
|
||||
return group.Columns
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func irisPrimaryColumnsFromColumns(columns []connection.ColumnDefinition) []string {
|
||||
primaryColumns := make([]string, 0)
|
||||
for _, column := range columns {
|
||||
if strings.EqualFold(strings.TrimSpace(column.Key), "PRI") && strings.TrimSpace(column.Name) != "" {
|
||||
primaryColumns = append(primaryColumns, column.Name)
|
||||
}
|
||||
}
|
||||
return primaryColumns
|
||||
}
|
||||
|
||||
type irisIndexGroup struct {
|
||||
Name string
|
||||
Columns []string
|
||||
NonUnique int
|
||||
IndexType string
|
||||
Primary bool
|
||||
}
|
||||
|
||||
func groupIRISIndexes(indexes []connection.IndexDefinition) []irisIndexGroup {
|
||||
groupsByName := map[string]*irisIndexGroup{}
|
||||
order := make([]string, 0)
|
||||
for _, idx := range indexes {
|
||||
name := strings.TrimSpace(idx.Name)
|
||||
column := strings.TrimSpace(idx.ColumnName)
|
||||
if name == "" || column == "" {
|
||||
continue
|
||||
}
|
||||
group, ok := groupsByName[name]
|
||||
if !ok {
|
||||
group = &irisIndexGroup{Name: name, NonUnique: idx.NonUnique, IndexType: idx.IndexType}
|
||||
groupsByName[name] = group
|
||||
order = append(order, name)
|
||||
}
|
||||
group.Columns = append(group.Columns, column)
|
||||
if idx.NonUnique == 0 {
|
||||
group.NonUnique = 0
|
||||
}
|
||||
if isIRISPrimaryIndex(idx) {
|
||||
group.Primary = true
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
groups := make([]irisIndexGroup, 0, len(order))
|
||||
for _, name := range order {
|
||||
group := groupsByName[name]
|
||||
groups = append(groups, *group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func buildIRISCreateIndexStatements(ref irisTableRef, indexes []connection.IndexDefinition) []string {
|
||||
qualified := irisQuoteIdent(ref.Table)
|
||||
if strings.TrimSpace(ref.Schema) != "" {
|
||||
qualified = irisQuoteIdent(ref.Schema) + "." + qualified
|
||||
}
|
||||
var statements []string
|
||||
for _, group := range groupIRISIndexes(indexes) {
|
||||
if len(group.Columns) == 0 || group.Primary {
|
||||
continue
|
||||
}
|
||||
unique := ""
|
||||
if group.NonUnique == 0 {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
statements = append(statements, fmt.Sprintf("CREATE %sINDEX %s ON %s (%s);", unique, irisQuoteIdent(group.Name), qualified, irisQuoteIdentList(group.Columns)))
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
func irisQuoteIdentList(columns []string) string {
|
||||
quoted := make([]string, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
quoted = append(quoted, irisQuoteIdent(column))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func buildIRISDeleteSQL(tableName string, keys map[string]interface{}) (string, []interface{}, bool) {
|
||||
wheres, args := irisAssignments(keys, " = ?")
|
||||
if len(wheres) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return fmt.Sprintf("DELETE FROM %s WHERE %s", irisQuoteTable(tableName), strings.Join(wheres, " AND ")), args, true
|
||||
}
|
||||
|
||||
func buildIRISUpdateSQL(tableName string, update connection.UpdateRow) (string, []interface{}, bool, error) {
|
||||
sets, args := irisAssignments(update.Values, " = ?")
|
||||
if len(sets) == 0 {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
wheres, whereArgs := irisAssignments(update.Keys, " = ?")
|
||||
if len(wheres) == 0 {
|
||||
return "", nil, false, fmt.Errorf("更新操作需要主键条件")
|
||||
}
|
||||
args = append(args, whereArgs...)
|
||||
return fmt.Sprintf("UPDATE %s SET %s WHERE %s", irisQuoteTable(tableName), strings.Join(sets, ", "), strings.Join(wheres, " AND ")), args, true, nil
|
||||
}
|
||||
|
||||
func buildIRISInsertSQL(tableName string, row map[string]interface{}) (string, []interface{}, bool) {
|
||||
if len(row) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
keys := sortedMapKeys(row)
|
||||
cols := make([]string, 0, len(keys))
|
||||
placeholders := make([]string, 0, len(keys))
|
||||
args := make([]interface{}, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
cols = append(cols, irisQuoteIdent(key))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, row[key])
|
||||
}
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", irisQuoteTable(tableName), strings.Join(cols, ", "), strings.Join(placeholders, ", ")), args, true
|
||||
}
|
||||
|
||||
func irisAssignments(values map[string]interface{}, suffix string) ([]string, []interface{}) {
|
||||
keys := sortedMapKeys(values)
|
||||
parts := make([]string, 0, len(keys))
|
||||
args := make([]interface{}, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, irisQuoteIdent(key)+suffix)
|
||||
args = append(args, values[key])
|
||||
}
|
||||
return parts, args
|
||||
}
|
||||
|
||||
func sortedMapKeys(values map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
275
internal/db/iris_impl_test.go
Normal file
275
internal/db/iris_impl_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
//go:build gonavi_full_drivers || gonavi_iris_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestIrisDSNUsesNamespaceDefaultPortAndConnectionParams(t *testing.T) {
|
||||
iris := &IrisDB{}
|
||||
|
||||
dsn := iris.getDSN(connection.ConnectionConfig{
|
||||
Host: "db.example.com",
|
||||
User: "_SYSTEM",
|
||||
Password: "p@ss",
|
||||
ConnectionParams: "timeout=30&ssl=1",
|
||||
})
|
||||
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse dsn: %v", err)
|
||||
}
|
||||
if parsed.Scheme != "iris" {
|
||||
t.Fatalf("scheme = %q", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host != "db.example.com:1972" {
|
||||
t.Fatalf("host = %q", parsed.Host)
|
||||
}
|
||||
if parsed.Path != "/USER" {
|
||||
t.Fatalf("namespace path = %q", parsed.Path)
|
||||
}
|
||||
if parsed.User.Username() != "_SYSTEM" {
|
||||
t.Fatalf("user = %q", parsed.User.Username())
|
||||
}
|
||||
password, _ := parsed.User.Password()
|
||||
if password != "p@ss" {
|
||||
t.Fatalf("password = %q", password)
|
||||
}
|
||||
if got := parsed.Query().Get("timeout"); got != "30" {
|
||||
t.Fatalf("timeout param = %q", got)
|
||||
}
|
||||
if got := parsed.Query().Get("ssl"); got != "1" {
|
||||
t.Fatalf("ssl param = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIRISURIExtractsConnectionFields(t *testing.T) {
|
||||
config := applyIRISURI(connection.ConnectionConfig{
|
||||
URI: "iris://user:secret@iris.local:1973/APP?timeout=30",
|
||||
Database: "SHOULD_BE_REPLACED",
|
||||
})
|
||||
|
||||
if config.Host != "iris.local" || config.Port != 1973 || config.User != "user" || config.Password != "secret" {
|
||||
t.Fatalf("unexpected parsed config: %#v", config)
|
||||
}
|
||||
if config.Database != "APP" {
|
||||
t.Fatalf("database namespace = %q", config.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIRISTableRefAndIdentifierQuoting(t *testing.T) {
|
||||
ref, err := parseIRISTableRef("Sample", `"Person.Table"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse table ref: %v", err)
|
||||
}
|
||||
if ref.Schema != "Sample" || ref.Table != "Person.Table" {
|
||||
t.Fatalf("unexpected ref: %#v", ref)
|
||||
}
|
||||
|
||||
ref, err = parseIRISTableRef("", `"Sample"."Person""Archive"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse qualified table ref: %v", err)
|
||||
}
|
||||
if ref.Schema != "Sample" || ref.Table != `Person"Archive` {
|
||||
t.Fatalf("unexpected qualified ref: %#v", ref)
|
||||
}
|
||||
if got := irisQuoteTable(`"Sample"."Person""Archive"`); got != `"Sample"."Person""Archive"` {
|
||||
t.Fatalf("quoted table = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIRISColumnKeyMapPrefersPrimaryThenUnique(t *testing.T) {
|
||||
keys := irisColumnKeyMap([]connection.IndexDefinition{
|
||||
{Name: "idx_id", ColumnName: "id", NonUnique: 0},
|
||||
{Name: "IDKEY", ColumnName: "id", NonUnique: 0},
|
||||
{Name: "idx_email", ColumnName: "email", NonUnique: 0},
|
||||
{Name: "idx_name", ColumnName: "name", NonUnique: 1},
|
||||
})
|
||||
|
||||
if keys["id"] != "PRI" {
|
||||
t.Fatalf("id key = %q", keys["id"])
|
||||
}
|
||||
if keys["email"] != "UNI" {
|
||||
t.Fatalf("email key = %q", keys["email"])
|
||||
}
|
||||
if keys["name"] != "" {
|
||||
t.Fatalf("name key = %q", keys["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIRISCreateTableDDLIncludesPrimaryAndIndexes(t *testing.T) {
|
||||
defaultValue := "CURRENT_TIMESTAMP"
|
||||
ddl := buildIRISCreateTableDDL(
|
||||
irisTableRef{Schema: "Sample", Table: "Person"},
|
||||
[]connection.ColumnDefinition{
|
||||
{Name: "id", Type: "INTEGER", Nullable: "NO"},
|
||||
{Name: "name", Type: "VARCHAR(80)", Nullable: "NO"},
|
||||
{Name: "created_at", Type: "TIMESTAMP", Nullable: "YES", Default: &defaultValue},
|
||||
},
|
||||
[]connection.IndexDefinition{
|
||||
{Name: "app_person_pk", ColumnName: "id", NonUnique: 0, SeqInIndex: 1, IndexType: "PRIMARY"},
|
||||
{Name: "idx_person_name", ColumnName: "name", NonUnique: 0, SeqInIndex: 1},
|
||||
{Name: "idx_person_created_at", ColumnName: "created_at", NonUnique: 1, SeqInIndex: 1},
|
||||
},
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
`CREATE TABLE "Sample"."Person"`,
|
||||
`"id" INTEGER NOT NULL`,
|
||||
`"name" VARCHAR(80) NOT NULL`,
|
||||
`"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP`,
|
||||
`PRIMARY KEY ("id")`,
|
||||
`CREATE UNIQUE INDEX "idx_person_name" ON "Sample"."Person" ("name");`,
|
||||
`CREATE INDEX "idx_person_created_at" ON "Sample"."Person" ("created_at");`,
|
||||
} {
|
||||
if !strings.Contains(ddl, want) {
|
||||
t.Fatalf("ddl missing %q:\n%s", want, ddl)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ddl, `CREATE UNIQUE INDEX "app_person_pk"`) {
|
||||
t.Fatalf("primary key index should not be emitted as a standalone index:\n%s", ddl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIRISCreateTableDDLFallsBackToColumnPrimaryKey(t *testing.T) {
|
||||
ddl := buildIRISCreateTableDDL(
|
||||
irisTableRef{Schema: "Sample", Table: "Person"},
|
||||
[]connection.ColumnDefinition{
|
||||
{Name: "id", Type: "INTEGER", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "name", Type: "VARCHAR(80)", Nullable: "YES"},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
if !strings.Contains(ddl, `PRIMARY KEY ("id")`) {
|
||||
t.Fatalf("ddl missing primary key from column metadata:\n%s", ddl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIrisMetadataMapsColumnsAndIndexes(t *testing.T) {
|
||||
dbConn, state := openOracleRecordingDB(t)
|
||||
iris := &IrisDB{conn: dbConn}
|
||||
|
||||
columnsQuery := buildIRISInfoSchemaWhereQuery("INFORMATION_SCHEMA.COLUMNS", irisTableRef{Schema: "Sample", Table: "Person"})
|
||||
indexesQuery := buildIRISInfoSchemaWhereQuery("INFORMATION_SCHEMA.INDEXES", irisTableRef{Schema: "Sample", Table: "Person"})
|
||||
|
||||
state.mu.Lock()
|
||||
state.queryResults[columnsQuery] = oracleRecordingQueryResult{
|
||||
columns: []string{"TABLE_SCHEMA", "TABLE_NAME", "COLUMN_NAME", "DATA_TYPE", "CHARACTER_MAXIMUM_LENGTH", "IS_NULLABLE", "COLUMN_DEFAULT", "ORDINAL_POSITION", "DESCRIPTION", "PRIMARY_KEY", "UNIQUE_COLUMN"},
|
||||
rows: [][]driver.Value{
|
||||
{"Sample", "Person", "id", "INTEGER", nil, "NO", nil, int64(1), "identifier", true, false},
|
||||
{"Sample", "Person", "name", "VARCHAR", int64(80), "YES", "'anonymous'", int64(2), "display name", false, true},
|
||||
},
|
||||
}
|
||||
state.queryResults[indexesQuery] = oracleRecordingQueryResult{
|
||||
columns: []string{"INDEX_NAME", "COLUMN_NAME", "NON_UNIQUE", "ORDINAL_POSITION", "INDEX_TYPE", "PRIMARY_KEY"},
|
||||
rows: [][]driver.Value{
|
||||
{"app_person_pk", "id", int64(1), int64(1), "bitmap", true},
|
||||
{"idx_person_name", "name", int64(0), int64(1), "", false},
|
||||
},
|
||||
}
|
||||
state.mu.Unlock()
|
||||
|
||||
columns, err := iris.GetColumns("Sample", "Person")
|
||||
if err != nil {
|
||||
t.Fatalf("GetColumns returned error: %v", err)
|
||||
}
|
||||
if len(columns) != 2 {
|
||||
t.Fatalf("columns len = %d", len(columns))
|
||||
}
|
||||
if columns[0].Name != "id" || columns[0].Key != "PRI" || columns[0].Nullable != "NO" {
|
||||
t.Fatalf("unexpected id column: %#v", columns[0])
|
||||
}
|
||||
if columns[1].Type != "VARCHAR(80)" || columns[1].Key != "UNI" {
|
||||
t.Fatalf("unexpected name column: %#v", columns[1])
|
||||
}
|
||||
|
||||
indexes, err := iris.GetIndexes("Sample", "Person")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIndexes returned error: %v", err)
|
||||
}
|
||||
if len(indexes) != 2 || indexes[0].Name != "app_person_pk" || indexes[0].IndexType != "PRIMARY" || indexes[0].NonUnique != 0 {
|
||||
t.Fatalf("unexpected indexes: %#v", indexes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIRISApplyChangesSQL(t *testing.T) {
|
||||
deleteSQL, deleteArgs, ok := buildIRISDeleteSQL("Sample.Person", map[string]interface{}{"id": 1})
|
||||
if !ok {
|
||||
t.Fatal("expected delete SQL")
|
||||
}
|
||||
if deleteSQL != `DELETE FROM "Sample"."Person" WHERE "id" = ?` || !reflect.DeepEqual(deleteArgs, []interface{}{1}) {
|
||||
t.Fatalf("unexpected delete SQL/args: %s %#v", deleteSQL, deleteArgs)
|
||||
}
|
||||
|
||||
updateSQL, updateArgs, ok, err := buildIRISUpdateSQL("Sample.Person", connection.UpdateRow{
|
||||
Keys: map[string]interface{}{"id": 1},
|
||||
Values: map[string]interface{}{"name": "Alice", "updated_at": "2026-05-16"},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("expected update SQL, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if updateSQL != `UPDATE "Sample"."Person" SET "name" = ?, "updated_at" = ? WHERE "id" = ?` {
|
||||
t.Fatalf("unexpected update SQL: %s", updateSQL)
|
||||
}
|
||||
if !reflect.DeepEqual(updateArgs, []interface{}{"Alice", "2026-05-16", 1}) {
|
||||
t.Fatalf("unexpected update args: %#v", updateArgs)
|
||||
}
|
||||
|
||||
insertSQL, insertArgs, ok := buildIRISInsertSQL("Sample.Person", map[string]interface{}{"name": "Alice", "id": 1})
|
||||
if !ok {
|
||||
t.Fatal("expected insert SQL")
|
||||
}
|
||||
if insertSQL != `INSERT INTO "Sample"."Person" ("id", "name") VALUES (?, ?)` {
|
||||
t.Fatalf("unexpected insert SQL: %s", insertSQL)
|
||||
}
|
||||
if !reflect.DeepEqual(insertArgs, []interface{}{1, "Alice"}) {
|
||||
t.Fatalf("unexpected insert args: %#v", insertArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIrisApplyChangesExecutesInDeleteUpdateInsertOrder(t *testing.T) {
|
||||
dbConn, state := openOracleRecordingDB(t)
|
||||
iris := &IrisDB{conn: dbConn}
|
||||
|
||||
err := iris.ApplyChanges("Sample.Person", connection.ChangeSet{
|
||||
Deletes: []map[string]interface{}{
|
||||
{"id": 3},
|
||||
},
|
||||
Updates: []connection.UpdateRow{
|
||||
{Keys: map[string]interface{}{"id": 2}, Values: map[string]interface{}{"name": "Bob"}},
|
||||
},
|
||||
Inserts: []map[string]interface{}{
|
||||
{"id": 1, "name": "Alice"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyChanges returned error: %v", err)
|
||||
}
|
||||
|
||||
got := state.snapshotExecQueries()
|
||||
want := []string{
|
||||
`DELETE FROM "Sample"."Person" WHERE "id" = ?`,
|
||||
`UPDATE "Sample"."Person" SET "name" = ? WHERE "id" = ?`,
|
||||
`INSERT INTO "Sample"."Person" ("id", "name") VALUES (?, ?)`,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected exec queries:\nwant=%#v\ngot=%#v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIRISUpdateSQLRequiresLocatorKeys(t *testing.T) {
|
||||
_, _, ok, err := buildIRISUpdateSQL("Person", connection.UpdateRow{
|
||||
Values: map[string]interface{}{"name": "Alice"},
|
||||
})
|
||||
if err == nil || ok {
|
||||
t.Fatalf("expected missing keys to be rejected, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user