= ({ tab }) => {
);
}
- if (error) {
+ if (error && !hasDefinition) {
return (
@@ -595,16 +644,21 @@ const DefinitionViewer: React.FC
= ({ tab }) => {
{tab.dbName && 数据库: {tab.dbName}}
{tab.routineType && 类型: {tab.routineType}}
- } onClick={openObjectEditQuery} disabled={!normalizedObjectName}>
+ } onClick={openObjectEditQuery} disabled={!normalizedObjectName} loading={openingObjectEdit}>
对象修改
+ {error && hasDefinition && (
+
+ )}
{
expect(messageApi.warning).not.toHaveBeenCalled();
});
+ it('keeps DuckDB qualified table query results writable when primary key metadata arrives', async () => {
+ storeState.connections[0].config.type = 'duckdb';
+ storeState.connections[0].config.database = 'main';
+ backendApp.DBQueryMulti.mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['NAME', '__gonavi_locator_1_id'], rows: [{ NAME: 'launch', __gonavi_locator_1_id: 7 }] }],
+ });
+ backendApp.DBGetColumns.mockResolvedValueOnce({
+ success: true,
+ data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }],
+ });
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ await act(async () => {
+ await findButton(renderer!, '运行').props.onClick();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'main.events');
+ expect(dataGridState.latestProps?.tableName).toBe('main.events');
+ expect(dataGridState.latestProps?.pkColumns).toEqual(['id']);
+ expect(dataGridState.latestProps?.editLocator).toMatchObject({
+ strategy: 'primary-key',
+ columns: ['id'],
+ valueColumns: ['__gonavi_locator_1_id'],
+ hiddenColumns: ['__gonavi_locator_1_id'],
+ writableColumns: {
+ NAME: 'name',
+ },
+ readOnly: false,
+ });
+ expect(dataGridState.latestProps?.readOnly).toBe(false);
+ expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"id" AS "__gonavi_locator_1_id"');
+ expect(messageApi.warning).not.toHaveBeenCalled();
+ });
+
it.each([
'mysql',
'mariadb',
diff --git a/frontend/src/components/TriggerViewer.object-edit.test.tsx b/frontend/src/components/TriggerViewer.object-edit.test.tsx
index 6f64db9..28eb4b9 100644
--- a/frontend/src/components/TriggerViewer.object-edit.test.tsx
+++ b/frontend/src/components/TriggerViewer.object-edit.test.tsx
@@ -86,6 +86,7 @@ describe('TriggerViewer object edit entry', () => {
storeState.addTab.mockReset();
storeState.setActiveContext.mockReset();
storeState.connections[0].config.type = 'postgres';
+ backendApp.DBQuery.mockReset();
backendApp.DBQuery.mockResolvedValue({
success: true,
data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
@@ -170,4 +171,102 @@ describe('TriggerViewer object edit entry', () => {
expect(query).toContain(':NEW.updated_at := SYSDATE;');
expect(query).not.toContain('请补全 CREATE TRIGGER 语句');
});
+
+ it('reloads the latest trigger definition before opening object edit', async () => {
+ backendApp.DBQuery
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT OR UPDATE ON audit.users EXECUTE FUNCTION audit.audit_users_v2();' }],
+ });
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create();
+ await flushPromises();
+ });
+
+ const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0];
+
+ await act(async () => {
+ await button.props.onClick();
+ await flushPromises();
+ });
+
+ expect(backendApp.DBQuery).toHaveBeenCalledTimes(2);
+ const query = storeState.addTab.mock.calls[0][0].query;
+ expect(query).toContain('CREATE TRIGGER users_bi BEFORE INSERT OR UPDATE ON audit.users');
+ expect(query).toContain('audit.audit_users_v2()');
+
+ const editor = renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0];
+ expect(String(editor.children.join(''))).toContain('CREATE TRIGGER users_bi BEFORE INSERT OR UPDATE ON audit.users');
+ });
+
+ it('keeps the current trigger definition visible when refresh for object edit fails', async () => {
+ backendApp.DBQuery
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
+ })
+ .mockResolvedValueOnce({
+ success: false,
+ message: 'refresh failed',
+ data: [],
+ });
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create();
+ await flushPromises();
+ });
+
+ const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0];
+
+ await act(async () => {
+ await button.props.onClick();
+ await flushPromises();
+ });
+
+ expect(storeState.addTab).not.toHaveBeenCalled();
+ expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('CREATE TRIGGER users_bi BEFORE INSERT ON audit.users');
+ expect(findButtonText(renderer.root)).toContain('刷新最新定义失败');
+ expect(findButtonText(renderer.root)).toContain('refresh failed');
+ });
+
+ it('does not keep the previous trigger definition when switching objects and the new load fails', async () => {
+ backendApp.DBQuery
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
+ })
+ .mockResolvedValueOnce({
+ success: false,
+ message: 'load failed',
+ data: [],
+ });
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create();
+ await flushPromises();
+ });
+
+ await act(async () => {
+ renderer.update();
+ await flushPromises();
+ });
+
+ expect(findButtonText(renderer.root)).toContain('加载失败');
+ expect(findButtonText(renderer.root)).toContain('load failed');
+ expect(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')).toHaveLength(0);
+ expect(findButtonText(renderer.root)).not.toContain('CREATE TRIGGER users_bi BEFORE INSERT');
+ });
});
diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx
index 21ace7b..ff5f984 100644
--- a/frontend/src/components/TriggerViewer.tsx
+++ b/frontend/src/components/TriggerViewer.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import Editor from './MonacoEditor';
import { Button, Spin, Alert } from 'antd';
import { EditOutlined } from '@ant-design/icons';
@@ -42,12 +42,23 @@ const TriggerViewer: React.FC = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [triggerDefinition, setTriggerDefinition] = useState('');
+ const [openingObjectEdit, setOpeningObjectEdit] = useState(false);
+ const isMountedRef = useRef(true);
+ const loadedDefinitionKeyRef = useRef('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const darkMode = theme === 'dark';
+ const objectIdentityKey = [
+ tab.connectionId,
+ tab.dbName,
+ tab.type,
+ tab.triggerName,
+ tab.triggerTableName,
+ tab.schemaName,
+ ].map((item) => String(item || '')).join('||');
// 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景)
@@ -242,79 +253,98 @@ LIMIT 1`];
}
};
- useEffect(() => {
- const loadTriggerDefinition = async () => {
- setLoading(true);
- setError(null);
+ const loadTriggerDefinition = async (): Promise<{ success: boolean; definition?: string; error?: string }> => {
+ const conn = connections.find(c => c.id === tab.connectionId);
+ if (!conn) {
+ return { success: false, error: '未找到数据库连接' };
+ }
- const conn = connections.find(c => c.id === tab.connectionId);
- if (!conn) {
- setError('未找到数据库连接');
- setLoading(false);
- return;
+ const triggerName = tab.triggerName || '';
+ const dbName = tab.dbName || '';
+
+ if (!triggerName) {
+ return { success: false, error: '触发器名称为空' };
+ }
+
+ const dialect = getMetadataDialect(conn);
+ const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
+ const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
+
+ if (!queries.length || String(queries[0] || '').startsWith('--')) {
+ return { success: true, definition: String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看') };
+ }
+
+ try {
+ const config = {
+ ...conn.config,
+ port: Number(conn.config.port),
+ password: conn.config.password || '',
+ database: conn.config.database || '',
+ useSSH: conn.config.useSSH || false,
+ ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
+ };
+
+ const result = await runQueryCandidates(config, dbName, queries);
+
+ if (result.success && Array.isArray(result.data) && result.data.length > 0) {
+ return { success: true, definition: extractTriggerDefinition(dialect, result.data) };
}
- const triggerName = tab.triggerName || '';
- const dbName = tab.dbName || '';
-
- if (!triggerName) {
- setError('触发器名称为空');
- setLoading(false);
- return;
- }
-
- const dialect = getMetadataDialect(conn);
- const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
- const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
-
- if (!queries.length || String(queries[0] || '').startsWith('--')) {
- setTriggerDefinition(String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看'));
- setLoading(false);
- return;
- }
-
- try {
- const config = {
- ...conn.config,
- port: Number(conn.config.port),
- password: conn.config.password || '',
- database: conn.config.database || '',
- useSSH: conn.config.useSSH || false,
- ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
- };
-
- const result = await runQueryCandidates(config, dbName, queries);
-
- if (result.success && Array.isArray(result.data) && result.data.length > 0) {
- const definition = extractTriggerDefinition(dialect, result.data);
- setTriggerDefinition(definition);
- return;
- }
-
- if (result.success) {
- if (sphinxLike) {
- const version = await getVersionHint(config, dbName);
- const versionText = version ? `(版本: ${version})` : '';
- setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
- return;
- }
- setTriggerDefinition('-- 未找到触发器定义');
- } else if (sphinxLike) {
+ if (result.success) {
+ if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version})` : '';
- setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
- } else {
- setError(result.message || '查询触发器定义失败');
+ return {
+ success: true,
+ definition: `-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`
+ };
}
- } catch (e: any) {
- setError('查询触发器定义失败: ' + (e?.message || String(e)));
- } finally {
- setLoading(false);
+ return { success: true, definition: '-- 未找到触发器定义' };
}
+
+ if (sphinxLike) {
+ const version = await getVersionHint(config, dbName);
+ const versionText = version ? `(版本: ${version})` : '';
+ return {
+ success: true,
+ definition: `-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`
+ };
+ }
+
+ return { success: false, error: result.message || '查询触发器定义失败' };
+ } catch (e: any) {
+ return { success: false, error: '查询触发器定义失败: ' + (e?.message || String(e)) };
+ }
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+ const syncTriggerDefinition = async () => {
+ setLoading(true);
+ setError(null);
+ const result = await loadTriggerDefinition();
+ if (cancelled) {
+ return;
+ }
+ if (result.success) {
+ loadedDefinitionKeyRef.current = objectIdentityKey;
+ setTriggerDefinition(String(result.definition || ''));
+ } else {
+ setError(result.error || '查询触发器定义失败');
+ }
+ setLoading(false);
};
- loadTriggerDefinition();
- }, [tab.connectionId, tab.dbName, tab.triggerName, connections]);
+ syncTriggerDefinition();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [tab.connectionId, tab.dbName, tab.triggerName, connections, objectIdentityKey]);
+
+ useEffect(() => () => {
+ isMountedRef.current = false;
+ }, []);
if (loading) {
return (
@@ -324,7 +354,10 @@ LIMIT 1`];
);
}
- if (error) {
+ const displayedDefinition = loadedDefinitionKeyRef.current === objectIdentityKey ? triggerDefinition : '';
+ const hasDefinition = String(displayedDefinition || '').trim() !== '';
+
+ if (error && !hasDefinition) {
return (
@@ -334,17 +367,36 @@ LIMIT 1`];
const triggerName = String(tab.triggerName || '').trim();
const dbName = String(tab.dbName || '').trim();
- const openObjectEditQuery = () => {
- if (!triggerName) return;
- setActiveContext({ connectionId: tab.connectionId, dbName });
- addTab({
- id: `query-edit-trigger-${tab.connectionId}-${dbName}-${Date.now()}`,
- title: `修改触发器: ${triggerName}`,
- type: 'query',
- connectionId: tab.connectionId,
- dbName,
- query: buildEditableTriggerSql(triggerName, triggerDefinition),
- });
+ const openObjectEditQuery = async () => {
+ if (!triggerName || openingObjectEdit) return;
+ setOpeningObjectEdit(true);
+ setError(null);
+ try {
+ const result = await loadTriggerDefinition();
+ if (!isMountedRef.current) {
+ return;
+ }
+ if (!result.success) {
+ setError(result.error || '查询触发器定义失败');
+ return;
+ }
+ const latestDefinition = String(result.definition || '');
+ loadedDefinitionKeyRef.current = objectIdentityKey;
+ setTriggerDefinition(latestDefinition);
+ setActiveContext({ connectionId: tab.connectionId, dbName });
+ addTab({
+ id: `query-edit-trigger-${tab.connectionId}-${dbName}-${Date.now()}`,
+ title: `修改触发器: ${triggerName}`,
+ type: 'query',
+ connectionId: tab.connectionId,
+ dbName,
+ query: buildEditableTriggerSql(triggerName, latestDefinition),
+ });
+ } finally {
+ if (isMountedRef.current) {
+ setOpeningObjectEdit(false);
+ }
+ }
};
return (
@@ -354,16 +406,21 @@ LIMIT 1`];
触发器: {tab.triggerName}
{tab.dbName &&
数据库: {tab.dbName}}
- } onClick={openObjectEditQuery} disabled={!triggerName}>
+ } onClick={openObjectEditQuery} disabled={!triggerName} loading={openingObjectEdit}>
对象修改
+ {error && hasDefinition && (
+
+ )}
{
});
});
+ it('keeps DuckDB schema-qualified table names for metadata lookups', () => {
+ expect(extractQueryResultTableRef('SELECT * FROM main.events LIMIT 500', 'duckdb', 'main'))
+ .toEqual({
+ tableName: 'main.events',
+ metadataDbName: 'main',
+ metadataTableName: 'main.events',
+ });
+ });
+
it('does not mark join results as editable table refs', () => {
expect(extractQueryResultTableRef('SELECT * FROM users u JOIN orders o ON u.id = o.user_id', 'oracle', 'APP'))
.toBeUndefined();
diff --git a/frontend/src/utils/queryResultTable.ts b/frontend/src/utils/queryResultTable.ts
index 98c1b4f..0d5548f 100644
--- a/frontend/src/utils/queryResultTable.ts
+++ b/frontend/src/utils/queryResultTable.ts
@@ -21,6 +21,11 @@ const isOracleLikeDialect = (dialect: string): boolean => {
return normalized === 'oracle' || normalized === 'dameng' || normalized === 'dm' || normalized === 'dm8';
};
+const keepsQualifiedTableNameForMetadata = (dialect: string): boolean => {
+ const normalized = String(dialect || '').trim().toLowerCase();
+ return normalized === 'duckdb';
+};
+
const isQuotedIdentifier = (part: string): boolean => {
const text = String(part || '').trim();
if (!text) return false;
@@ -73,13 +78,16 @@ export const extractQueryResultTableRef = (
const owner = parts.length >= 2 ? parts[parts.length - 2] : '';
const metadataDbName = owner || normalizeCurrentDbName(currentDb, dialect);
- const tableName = isOracleLikeDialect(dialect) && owner
+ const tableName = (isOracleLikeDialect(dialect) || keepsQualifiedTableNameForMetadata(dialect)) && owner
+ ? `${owner}.${metadataTableName}`
+ : metadataTableName;
+ const resolvedMetadataTableName = keepsQualifiedTableNameForMetadata(dialect) && owner
? `${owner}.${metadataTableName}`
: metadataTableName;
return {
tableName,
metadataDbName,
- metadataTableName,
+ metadataTableName: resolvedMetadataTableName,
};
};
diff --git a/internal/app/methods_db_metadata_retry_test.go b/internal/app/methods_db_metadata_retry_test.go
index fefbf3b..a571d18 100644
--- a/internal/app/methods_db_metadata_retry_test.go
+++ b/internal/app/methods_db_metadata_retry_test.go
@@ -2,6 +2,7 @@ package app
import (
"errors"
+ "path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
@@ -9,6 +10,17 @@ import (
"GoNavi-Wails/internal/secretstore"
)
+func requireDuckDBOptionalDriverRuntime(t *testing.T) {
+ t.Helper()
+
+ if !db.IsOptionalGoDriverBuildIncluded("duckdb") {
+ t.Skip("当前构建未包含 DuckDB 可选驱动")
+ }
+ if ready, reason := db.DriverRuntimeSupportStatus("duckdb"); !ready {
+ t.Skipf("DuckDB runtime 未就绪,跳过集成测试: %s", reason)
+ }
+}
+
type fakeMetadataRetryDB struct {
columns []connection.ColumnDefinition
indexes []connection.IndexDefinition
@@ -224,6 +236,38 @@ func TestDBGetColumnsKeepsDatabaseForMySQLMetadata(t *testing.T) {
}
}
+func TestDBGetColumnsKeepsDuckDBQualifiedTableMetadata(t *testing.T) {
+ originalNewDatabaseFunc := newDatabaseFunc
+ originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
+ t.Cleanup(func() {
+ newDatabaseFunc = originalNewDatabaseFunc
+ resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
+ })
+
+ dbInst := &fakeMetadataRetryDB{
+ columns: []connection.ColumnDefinition{{Name: "id", Key: "PRI"}},
+ }
+ newDatabaseFunc = func(dbType string) (db.Database, error) {
+ return dbInst, nil
+ }
+ resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
+ return raw, nil
+ }
+
+ app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
+ result := app.DBGetColumns(connection.ConnectionConfig{
+ Type: "duckdb",
+ Host: "D:/tmp/demo.duckdb",
+ }, "main", "main.events")
+
+ if !result.Success {
+ t.Fatalf("expected DBGetColumns success, got failure: %s", result.Message)
+ }
+ if dbInst.columnSchema != "main" || dbInst.columnTable != "main.events" {
+ t.Fatalf("expected duckdb metadata to preserve main/main.events, got %q.%q", dbInst.columnSchema, dbInst.columnTable)
+ }
+}
+
func TestDBGetIndexesRetriesAfterCachedConnectionRefresh(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
@@ -276,3 +320,106 @@ func TestDBGetIndexesRetriesAfterCachedConnectionRefresh(t *testing.T) {
t.Fatalf("unexpected indexes after retry: %#v", indexes)
}
}
+
+func TestDBGetIndexesKeepsDuckDBQualifiedTableMetadata(t *testing.T) {
+ originalNewDatabaseFunc := newDatabaseFunc
+ originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
+ t.Cleanup(func() {
+ newDatabaseFunc = originalNewDatabaseFunc
+ resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
+ })
+
+ dbInst := &fakeMetadataRetryDB{
+ indexes: []connection.IndexDefinition{{Name: "events_id_pkey", ColumnName: "id", NonUnique: 0}},
+ }
+ newDatabaseFunc = func(dbType string) (db.Database, error) {
+ return dbInst, nil
+ }
+ resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
+ return raw, nil
+ }
+
+ app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
+ result := app.DBGetIndexes(connection.ConnectionConfig{
+ Type: "duckdb",
+ Host: "D:/tmp/demo.duckdb",
+ }, "main", "main.events")
+
+ if !result.Success {
+ t.Fatalf("expected DBGetIndexes success, got failure: %s", result.Message)
+ }
+ if dbInst.indexSchema != "main" || dbInst.indexTable != "main.events" {
+ t.Fatalf("expected duckdb index metadata to preserve main/main.events, got %q.%q", dbInst.indexSchema, dbInst.indexTable)
+ }
+}
+
+func TestDuckDBMetadataEndpointsReturnPrimaryKeyForQualifiedTableName(t *testing.T) {
+ t.Parallel()
+ requireDuckDBOptionalDriverRuntime(t)
+
+ originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
+ t.Cleanup(func() {
+ resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
+ })
+ resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
+ return raw, nil
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "duckdb-primary-key.duckdb")
+ app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
+ config := connection.ConnectionConfig{
+ Type: "duckdb",
+ Host: dbPath,
+ }
+ t.Cleanup(func() {
+ app.invalidateCachedDatabase(config, nil)
+ })
+
+ createResult := app.DBQuery(config, "main", `
+CREATE TABLE main.events (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR
+);
+CREATE UNIQUE INDEX idx_events_name ON main.events(name);
+`)
+ if !createResult.Success {
+ t.Fatalf("expected DuckDB setup success, got failure: %s", createResult.Message)
+ }
+
+ columnResult := app.DBGetColumns(config, "main", "main.events")
+ if !columnResult.Success {
+ t.Fatalf("expected DBGetColumns success, got failure: %s", columnResult.Message)
+ }
+ columns, ok := columnResult.Data.([]connection.ColumnDefinition)
+ if !ok {
+ t.Fatalf("expected []connection.ColumnDefinition, got %T", columnResult.Data)
+ }
+ if len(columns) == 0 {
+ t.Fatalf("expected DuckDB columns, got %#v", columns)
+ }
+ if columns[0].Name != "id" || columns[0].Key != "PRI" {
+ t.Fatalf("expected primary key metadata on first column, got %#v", columns)
+ }
+
+ indexResult := app.DBGetIndexes(config, "main", "main.events")
+ if !indexResult.Success {
+ t.Fatalf("expected DBGetIndexes success, got failure: %s", indexResult.Message)
+ }
+ indexes, ok := indexResult.Data.([]connection.IndexDefinition)
+ if !ok {
+ t.Fatalf("expected []connection.IndexDefinition, got %T", indexResult.Data)
+ }
+ if len(indexes) == 0 {
+ t.Fatalf("expected DuckDB indexes, got %#v", indexes)
+ }
+ foundPrimary := false
+ for _, index := range indexes {
+ if index.ColumnName == "id" && index.NonUnique == 0 {
+ foundPrimary = true
+ break
+ }
+ }
+ if !foundPrimary {
+ t.Fatalf("expected DuckDB primary key index metadata, got %#v", indexes)
+ }
+}
diff --git a/internal/db/duckdb_metadata_integration_test.go b/internal/db/duckdb_metadata_integration_test.go
index 153ef27..828b5fc 100644
--- a/internal/db/duckdb_metadata_integration_test.go
+++ b/internal/db/duckdb_metadata_integration_test.go
@@ -4,6 +4,7 @@ package db
import (
"path/filepath"
+ "strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -66,6 +67,81 @@ CREATE UNIQUE INDEX idx_events_name ON events(name);
}
}
+func TestDuckDBDefinitionReloadReflectsLatestDDL(t *testing.T) {
+ t.Parallel()
+
+ dbPath := filepath.Join(t.TempDir(), "definition-reload.duckdb")
+ client := &DuckDB{}
+ if err := client.Connect(connection.ConnectionConfig{Type: "duckdb", Host: dbPath}); err != nil {
+ t.Fatalf("Connect failed: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = client.Close()
+ })
+
+ if _, err := client.Exec(`
+CREATE VIEW active_users AS
+SELECT id FROM (VALUES (1), (2)) AS users(id);
+
+CREATE OR REPLACE MACRO add_one(x) AS x + 1;
+`); err != nil {
+ t.Fatalf("create initial objects failed: %v", err)
+ }
+
+ viewDefinitionBefore, _, err := client.Query(`SELECT view_definition FROM information_schema.views WHERE table_schema = 'main' AND table_name = 'active_users' LIMIT 1`)
+ if err != nil {
+ t.Fatalf("query initial view definition failed: %v", err)
+ }
+ if len(viewDefinitionBefore) != 1 {
+ t.Fatalf("expected one initial view definition row, got %+v", viewDefinitionBefore)
+ }
+ if got := duckDBRowString(viewDefinitionBefore[0], "view_definition"); !strings.Contains(got, "SELECT id FROM") || !strings.Contains(got, "VALUES (1), (2)") {
+ t.Fatalf("unexpected initial view definition: %q", got)
+ }
+
+ routineDefinitionBefore, _, err := client.Query(`SELECT macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = 'main' AND function_name = 'add_one' LIMIT 1`)
+ if err != nil {
+ t.Fatalf("query initial macro definition failed: %v", err)
+ }
+ if len(routineDefinitionBefore) != 1 {
+ t.Fatalf("expected one initial macro definition row, got %+v", routineDefinitionBefore)
+ }
+ if got := duckDBRowString(routineDefinitionBefore[0], "macro_definition"); !strings.Contains(got, "x + 1") {
+ t.Fatalf("unexpected initial macro definition: %q", got)
+ }
+
+ if _, err := client.Exec(`
+CREATE OR REPLACE VIEW active_users AS
+SELECT id, id * 10 AS score FROM (VALUES (1), (2)) AS users(id);
+
+CREATE OR REPLACE MACRO add_one(x) AS x + 2;
+`); err != nil {
+ t.Fatalf("replace latest objects failed: %v", err)
+ }
+
+ viewDefinitionAfter, _, err := client.Query(`SELECT view_definition FROM information_schema.views WHERE table_schema = 'main' AND table_name = 'active_users' LIMIT 1`)
+ if err != nil {
+ t.Fatalf("query latest view definition failed: %v", err)
+ }
+ if len(viewDefinitionAfter) != 1 {
+ t.Fatalf("expected one latest view definition row, got %+v", viewDefinitionAfter)
+ }
+ if got := duckDBRowString(viewDefinitionAfter[0], "view_definition"); !strings.Contains(got, "SELECT id") || !strings.Contains(got, "score") || !strings.Contains(got, "10") {
+ t.Fatalf("expected latest view definition, got %q", got)
+ }
+
+ routineDefinitionAfter, _, err := client.Query(`SELECT macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = 'main' AND function_name = 'add_one' LIMIT 1`)
+ if err != nil {
+ t.Fatalf("query latest macro definition failed: %v", err)
+ }
+ if len(routineDefinitionAfter) != 1 {
+ t.Fatalf("expected one latest macro definition row, got %+v", routineDefinitionAfter)
+ }
+ if got := duckDBRowString(routineDefinitionAfter[0], "macro_definition"); !strings.Contains(got, "x + 2") {
+ t.Fatalf("expected latest macro definition, got %q", got)
+ }
+}
+
func duckDBTestHasUniqueIndexColumn(indexes []connection.IndexDefinition, columnName string) bool {
for _, index := range indexes {
if index.ColumnName == columnName && index.NonUnique == 0 {