fix(oracle): restore alias completion and DIP visibility

This commit is contained in:
zipg
2026-07-22 01:14:56 +08:00
committed by GitHub
parent 785c4098d1
commit 25e799c34b
18 changed files with 394 additions and 61 deletions
+11 -5
View File
@@ -49,7 +49,7 @@ WHERE username IS NOT NULL
AND username NOT IN (
'SYS','SYSTEM','SYSMAN','DBSNMP','SYSBACKUP','SYSDG','SYSKM','SYSRAC','OUTLN',
'AUDSYS','LBACSYS','DVF','DVSYS','APPQOSSYS','CTXSYS','MDSYS','MDDATA',
'ORDSYS','ORDDATA','ORDPLUGINS','XDB','ANONYMOUS','DIP','EXFSYS',
'ORDSYS','ORDDATA','ORDPLUGINS','XDB','ANONYMOUS','EXFSYS',
'GSMADMIN_INTERNAL','GSMCATUSER','GSMROOTUSER','GSMUSER','OJVMSYS','OLAPSYS',
'ORACLE_OCM','SI_INFORMTN_SCHEMA','WMSYS','XS$NULL','DBSFWUSER',
'REMOTE_SCHEDULER_AGENT','PDBADMIN','DGPDB_INT','OPS$ORACLE',
@@ -1135,11 +1135,17 @@ func (s *server) currentSchema() (string, error) {
}
func (s *server) normalizeSchema(schema string) (string, error) {
schema = strings.TrimSpace(schema)
if schema == "" {
return s.currentSchema()
return resolveOracleSchema(schema, s.currentSchema, s.sessionUser)
}
func resolveOracleSchema(schema string, currentSchema, sessionUser func() (string, error)) (string, error) {
if schema = strings.TrimSpace(schema); schema != "" {
return strings.ToUpper(schema), nil
}
return strings.ToUpper(schema), nil
if current, err := currentSchema(); err == nil && strings.TrimSpace(current) != "" {
return strings.ToUpper(strings.TrimSpace(current)), nil
}
return sessionUser()
}
func (s *server) sessionUser() (string, error) {
+38
View File
@@ -652,6 +652,9 @@ func TestListDatabasesSQLUsesUserDictionaryInsteadOfObjectDictionary(t *testing.
if strings.Contains(sqlText, "ALL_TABLES") || strings.Contains(sqlText, "ALL_VIEWS") {
t.Fatalf("schema listing should not scan object dictionaries, got: %s", oracleListDatabasesSQL)
}
if strings.Contains(sqlText, "'DIP'") {
t.Fatalf("schema listing should not hide an existing user named DIP, got: %s", oracleListDatabasesSQL)
}
}
func TestListDatabasesSQLCanApplyVisibleSchemaFilter(t *testing.T) {
@@ -672,6 +675,41 @@ func TestListDatabasesSQLCanApplyVisibleSchemaFilter(t *testing.T) {
}
}
func TestResolveOracleSchemaPrefersCurrentSchemaOverSessionUser(t *testing.T) {
currentCalls := 0
sessionUserCalls := 0
schema, err := resolveOracleSchema(
"",
func() (string, error) {
currentCalls++
return "REPORTING", nil
},
func() (string, error) {
sessionUserCalls++
return "APP", nil
},
)
if err != nil || schema != "REPORTING" {
t.Fatalf("resolved schema = %q, err = %v; want REPORTING", schema, err)
}
if currentCalls != 1 || sessionUserCalls != 0 {
t.Fatalf("unexpected resolver calls: current=%d session_user=%d", currentCalls, sessionUserCalls)
}
}
func TestResolveOracleSchemaFallsBackToSessionUser(t *testing.T) {
schema, err := resolveOracleSchema(
"",
func() (string, error) { return "", errors.New("CURRENT_SCHEMA unavailable") },
func() (string, error) { return "APP", nil },
)
if err != nil || schema != "APP" {
t.Fatalf("resolved schema = %q, err = %v; want APP", schema, err)
}
}
func TestListTablesSQLUsesSplitDictionaryQuery(t *testing.T) {
sqlText := strings.ToUpper(oracleListTablesSQL)
@@ -43,6 +43,7 @@ import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget }
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
import {
@@ -95,6 +96,8 @@ const props = defineProps<{
connectionId?: string;
database?: string;
schema?: string;
clientSessionId?: string;
completionContextVersion?: number;
databaseType?: DatabaseType;
dialect?: "mysql" | "postgres" | "sqlserver";
syntaxDialect?: "mysql" | "postgres" | "sqlserver";
@@ -350,6 +353,38 @@ const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
return shouldUseOracleSessionCompletionColumns({
databaseType: props.databaseType,
selectedSchema: props.schema,
referenceSchema: schema,
clientSessionId: props.clientSessionId,
});
}
function completionColumnRequestContext() {
return {
clientSessionId: props.clientSessionId,
version: props.completionContextVersion,
};
}
async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string) {
const requestedVersion = props.completionContextVersion;
const sessionScoped = usesOracleSessionCompletionColumns(schema);
const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext());
if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context");
return columns;
}
async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string) {
const requestedVersion = props.completionContextVersion;
const sessionScoped = usesOracleSessionCompletionColumns(schema);
const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext());
if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context");
return columns;
}
const zoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
if (settingsStore.editorSettings.fontSize === fontSize) return;
settingsStore.updateEditorSettings({ fontSize });
@@ -1378,7 +1413,7 @@ function requestInsertValueHintTableColumns(table: string, schema?: string, data
cachedInsertValueHintColumnsByTable.set(cacheKey, insertValueHintColumnNames(databaseType, columns));
return;
}
const columns = await connectionStore.listCompletionColumns(connectionId, target.database, table, target.schema);
const columns = await listCompletionColumnsForEditor(connectionId, target.database, table, target.schema);
cachedColumnsByTable.set(cacheKey, columns);
};
void loadColumns()
@@ -1458,7 +1493,7 @@ async function ensureColumnsForTable(table: { name: string; schema?: string | nu
if (!props.connectionId || props.database == null) return false;
const target = completionMetadataTarget(table);
if (!target) return false;
const columns = await connectionStore.listCompletionColumns(props.connectionId, target.database, table.name, target.schema);
const columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema);
cachedColumnsByTable.set(cacheKey, columns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
return true;
@@ -1782,6 +1817,10 @@ async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]): Prom
enriched.push(table);
continue;
}
if (usesOracleSessionCompletionColumns(table.schema)) {
enriched.push(table);
continue;
}
try {
const match = await findExactSemanticDiagnosticTable(table);
if (!match) missingTables.add(tableReferenceKey(table));
@@ -2439,7 +2478,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
if (completionContext.insertTable) {
const insertSchema = completionContext.insertSchema ?? props.schema;
const insertColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, completionContext.insertTable, insertSchema);
const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, completionContext.insertTable, insertSchema);
if (insertColumns.length > 0) {
columnsByTable.set(insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable, insertColumns);
}
@@ -2453,7 +2492,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
columnsByTable.set(cacheKey, cached);
} else {
const target = completionMetadataTarget(qualifiedColumnTarget);
const localColumns = target ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema) : [];
const localColumns = target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema) : [];
if (localColumns.length > 0) {
columnsByTable.set(cacheKey, localColumns);
}
@@ -2482,7 +2521,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
continue;
}
const target = completionMetadataTarget(refTable);
const localColumns = target ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema) : [];
const localColumns = target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema) : [];
if (localColumns.length > 0) {
columnsByTable.set(cacheKey, localColumns);
}
@@ -2558,8 +2597,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
}
if (!onDemandOnlyColumns && completionContext.insertTable) {
const insertTable = completionContext.insertTable;
void connectionStore
.refreshCompletionColumns(connectionId, database, insertTable, completionContext.insertSchema ?? props.schema)
void refreshCompletionColumnsForEditor(connectionId, database, insertTable, completionContext.insertSchema ?? props.schema)
.then((columns) => {
const insertSchema = completionContext.insertSchema ?? props.schema;
cachedColumnsByTable.set(insertSchema ? `${insertSchema}.${insertTable}` : insertTable, columns);
@@ -2571,8 +2609,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
if (!onDemandOnlyColumns && qualifiedColumnTarget && qualifiedColumnCacheKey && !cachedColumnsByTable.has(qualifiedColumnCacheKey)) {
const target = completionMetadataTarget(qualifiedColumnTarget);
if (target) {
void connectionStore
.refreshCompletionColumns(connectionId, target.database, qualifiedColumnTarget.name, target.schema)
void refreshCompletionColumnsForEditor(connectionId, target.database, qualifiedColumnTarget.name, target.schema)
.then((columns) => {
if (columns.length > 0) cachedColumnsByTable.set(qualifiedColumnCacheKey, columns);
})
@@ -2588,8 +2625,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
if (cachedColumnsByTable.has(cacheKey)) continue;
const target = completionMetadataTarget(refTable);
if (!target) continue;
void connectionStore
.refreshCompletionColumns(connectionId, target.database, refTable.name, target.schema)
void refreshCompletionColumnsForEditor(connectionId, target.database, refTable.name, target.schema)
.then((columns) => {
if (columns.length > 0) cachedColumnsByTable.set(cacheKey, columns);
})
@@ -2692,7 +2728,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
if (completionContext.insertTable) {
try {
const insertCols = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, completionContext.insertTable, completionContext.insertSchema ?? props.schema);
const insertCols = await listCompletionColumnsForEditor(props.connectionId!, props.database!, completionContext.insertTable, completionContext.insertSchema ?? props.schema);
if (epoch !== completionEpoch) return null;
if (insertCols.length > 0) {
const insertSchema = completionContext.insertSchema ?? props.schema;
@@ -2776,6 +2812,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
// Collect referenced tables — enrich with schema from filtered table lookup
let refs = completionContext.referencedTables.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
if (!rt.schema) {
const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase());
if (cached && cached.schema) {
@@ -2784,12 +2821,13 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
}
return rt;
});
const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns && !isVirtualCompletionTableReference(rt));
const unresolvedRefs = refs.filter((rt) => !usesOracleSessionCompletionColumns(rt.schema) && !rt.schema && !rt.columns && !isVirtualCompletionTableReference(rt));
if (!localOnlyMetadata && unresolvedRefs.length > 0) {
const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema, false, props.schema)));
if (epoch !== completionEpoch) return null;
const lookupTables = lookupGroups.flat();
refs = refs.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
if (rt.schema || rt.columns) return rt;
const matched = lookupTables.find((table) => table.name.toLowerCase() === rt.name.toLowerCase());
return matched?.schema ? { ...rt, schema: matched.schema } : rt;
@@ -2830,7 +2868,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
try {
const target = completionMetadataTarget(refTable);
if (!target) return;
const columns = await connectionStore.listCompletionColumns(props.connectionId!, target.database, refTable.name, target.schema);
const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema);
if (epoch !== completionEpoch) return;
if (columns.length === 0) return;
cachedColumnsByTable.set(cacheKey, columns);
@@ -3639,6 +3677,7 @@ onMounted(async () => {
let referencedTables: Array<SqlCompletionReferencedTable & Pick<SqlCompletionTable, "type">> = context.referencedTables;
// Enrich referenced tables with schema from cachedTables
referencedTables = referencedTables.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase() && (!rt.schema || !ct.schema || ct.schema.toLowerCase() === rt.schema.toLowerCase()));
if (!cached) return rt;
return {
@@ -3682,7 +3721,8 @@ onMounted(async () => {
let cols = cachedColumnsByTable.get(cacheKey);
if (!cols) {
try {
cols = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, refTable.name, refTable.schema ?? props.schema);
const columnSchema = refTable.schema ?? props.schema;
cols = await listCompletionColumnsForEditor(props.connectionId!, props.database!, refTable.name, columnSchema);
cachedColumnsByTable.set(cacheKey, cols);
} catch {
continue;
@@ -3821,6 +3861,13 @@ watch(
},
);
watch([() => props.clientSessionId, () => props.completionContextVersion], () => {
completionEpoch++;
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
});
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => {
executableStatementRangeCache = null;
if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension || !sqlSignatureComp || !buildSqlSignatureExtension) return;
@@ -814,6 +814,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:schema="activeTab.schema"
:client-session-id="activeTab.id"
:completion-context-version="activeTab.completionContextVersion"
:database-type="activeEffectiveDatabaseType"
:dialect="editorDialect"
:syntax-dialect="editorSyntaxDialect"
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { usesOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
describe("Oracle session-scoped completion", () => {
it("uses the tab session only for unqualified references without a selected schema", () => {
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a" })).toBe(true);
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a", referenceSchema: "REPORTING" })).toBe(false);
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a", selectedSchema: "REPORTING" })).toBe(false);
expect(usesOracleSessionCompletionColumns({ databaseType: "postgres", clientSessionId: "tab-a" })).toBe(false);
expect(usesOracleSessionCompletionColumns({ databaseType: "oracle" })).toBe(false);
});
it("passes tab context into QueryEditor and clears local caches when the tab or context changes", () => {
expect(contentAreaSource).toContain(':client-session-id="activeTab.id"');
expect(contentAreaSource).toContain(':completion-context-version="activeTab.completionContextVersion"');
expect(queryEditorSource).toMatch(/watch\(\s*\[\(\) => props\.clientSessionId, \(\) => props\.completionContextVersion\][\s\S]*?refreshCompletionCache\(\)/);
});
});
+2 -2
View File
@@ -640,8 +640,8 @@ export async function getObjectSource(connectionId: string, database: string, sc
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature })}`);
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ColumnInfo[]> {
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId })}`);
}
export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise<SqlServerColumnMetadata[]> {
+2 -2
View File
@@ -801,8 +801,8 @@ export async function listSchemaInfos(connectionId: string, database: string): P
return invoke("list_schema_infos", { connectionId, database });
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ColumnInfo[]> {
return invoke("get_columns", { connectionId, database, schema, table, catalog });
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
return invoke("get_columns", { connectionId, database, schema, table, catalog, clientSessionId });
}
export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise<SqlServerColumnMetadata[]> {
@@ -26,7 +26,6 @@ const SYSTEM_DATABASE_RULES: Partial<Record<DatabaseType, ReadonlySet<string>>>
"audsys",
"ctxsys",
"dbsnmp",
"dip",
"dvf",
"dvsys",
"exfsys",
@@ -0,0 +1,5 @@
import type { DatabaseType } from "@/types/database";
export function usesOracleSessionCompletionColumns(options: { databaseType?: DatabaseType; selectedSchema?: string; referenceSchema?: string | null; clientSessionId?: string }): boolean {
return options.databaseType === "oracle" && !options.selectedSchema && !options.referenceSchema && !!options.clientSessionId;
}
@@ -232,6 +232,95 @@ describe("connectionStore completion assistant", () => {
]);
});
it("lets Oracle resolve CURRENT_SCHEMA for unqualified column completion", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [],
incomplete: false,
fallback_used: false,
});
const getColumns = vi.fn().mockResolvedValue([{ name: "REPORT_ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
completionAssistantSearch,
getColumns,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oracleConnection()];
store.connectedIds.add("oracle-1");
const columns = await store.listCompletionColumns("oracle-1", "ORCL", "ORDERS", undefined, { clientSessionId: "tab-a", version: 0 });
expect(completionAssistantSearch).not.toHaveBeenCalled();
expect(getColumns).toHaveBeenCalledWith("oracle-1", "ORCL", "", "ORDERS", undefined, "tab-a");
expect(columns).toEqual([expect.objectContaining({ name: "REPORT_ID", table: "ORDERS", schema: undefined, dataType: "NUMBER" })]);
expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "ORDERS")).toEqual([]);
});
it("isolates Oracle CURRENT_SCHEMA column caches by tab and context version", async () => {
const completionAssistantSearch = vi.fn();
const getColumns = vi
.fn()
.mockResolvedValueOnce([{ name: "APP_ID", data_type: "NUMBER", is_nullable: false }])
.mockResolvedValueOnce([{ name: "REPORT_ID", data_type: "NUMBER", is_nullable: false }])
.mockResolvedValueOnce([{ name: "SYSTEM_ID", data_type: "NUMBER", is_nullable: false }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
completionAssistantSearch,
getColumns,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oracleConnection()];
store.connectedIds.add("oracle-1");
const app = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-a", version: 0 });
const cachedApp = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-a", version: 0 });
const reporting = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-a", version: 1 });
const independentTab = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-b", version: 0 });
expect(app.map((column) => column.name)).toEqual(["APP_ID"]);
expect(cachedApp.map((column) => column.name)).toEqual(["APP_ID"]);
expect(reporting.map((column) => column.name)).toEqual(["REPORT_ID"]);
expect(independentTab.map((column) => column.name)).toEqual(["SYSTEM_ID"]);
expect(getColumns).toHaveBeenCalledTimes(3);
expect(getColumns.mock.calls.map((call) => call[5])).toEqual(["tab-a", "tab-a", "tab-b"]);
expect(completionAssistantSearch).not.toHaveBeenCalled();
});
it("keeps explicit Oracle schema completion on the shared assistant path", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [{ name: "EXPLICIT_ID", kind: "column", schema: "REPORTING", parent_schema: "REPORTING", parent_name: "SHARED_TABLE", data_type: "NUMBER" }],
incomplete: false,
fallback_used: false,
});
const getColumns = vi.fn();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
completionAssistantSearch,
getColumns,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [oracleConnection()];
store.connectedIds.add("oracle-1");
const columns = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", "REPORTING", { clientSessionId: "tab-a", version: 2 });
expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "REPORTING", parent_schema: "REPORTING", parent_name: "SHARED_TABLE" }));
expect(getColumns).not.toHaveBeenCalled();
expect(columns).toEqual([expect.objectContaining({ name: "EXPLICIT_ID", schema: "REPORTING" })]);
});
it("maps Oracle package members without scanning every schema", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [{ name: "CALCULATE_BONUS", kind: "function", schema: "HR", parent_schema: "HR", parent_name: "PAYROLL", data_type: "FUNCTION" }],
@@ -83,6 +83,51 @@ describe("queryStore multi-statement errors", () => {
expect(tab.result?.columns).toEqual(["Error"]);
});
it("invalidates only the executing Oracle tab after successful CURRENT_SCHEMA changes", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "oracle-1",
name: "Oracle",
db_type: "oracle",
database: "ORCL",
query_timeout_secs: 30,
});
mocks.executeMulti
.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }])
.mockResolvedValueOnce([{ columns: ["Error"], rows: [["schema missing"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }])
.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabA = store.createTab("oracle-1", "ORCL", "Tab A");
const tabB = store.createTab("oracle-1", "ORCL", "Tab B");
await store.executeTabSql(tabA, "ALTER SESSION SET CURRENT_SCHEMA = REPORTING");
expect(store.tabs.find((tab) => tab.id === tabA)?.completionContextVersion).toBe(1);
expect(store.tabs.find((tab) => tab.id === tabB)?.completionContextVersion).toBeUndefined();
await store.executeTabSql(tabA, "/* retry */ ALTER SESSION SET CURRENT_SCHEMA = MISSING");
expect(store.tabs.find((tab) => tab.id === tabA)?.completionContextVersion).toBe(1);
await store.executeTabSql(tabA, "-- switch back\nALTER SESSION SET CURRENT_SCHEMA = APP");
expect(store.tabs.find((tab) => tab.id === tabA)?.completionContextVersion).toBe(2);
expect(mocks.executeMulti.mock.calls.map((call) => call[5]?.clientSessionId)).toEqual([tabA, tabA, tabA]);
});
it("invalidates Oracle completion metadata when clearing a tab schema resets its session", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "oracle-1",
name: "Oracle",
db_type: "oracle",
database: "ORCL",
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Oracle", "query", "REPORTING");
store.updateSchema(tabId, undefined);
expect(store.tabs.find((tab) => tab.id === tabId)?.completionContextVersion).toBe(1);
});
it("preserves the selected statement's absolute editor range", async () => {
mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
+35 -29
View File
@@ -2281,7 +2281,7 @@ export const useConnectionStore = defineStore("connection", () => {
} else if (config && connectionUsesVisibleSchemaFilter(config)) {
const schemaFilterConfig = config;
const effectiveDb = schemaFilterConfig.database || "";
const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas");
const cacheKey = schemaCacheKey(connectionId, effectiveDb, config.db_type === "oracle" ? "schemas-v2" : "schemas");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
@@ -4781,39 +4781,45 @@ export const useConnectionStore = defineStore("connection", () => {
return deduped;
}
async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionColumn[]> {
if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(getConfig(connectionId)) && !schema) {
async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }): Promise<SqlCompletionColumn[]> {
const config = getConfig(connectionId);
const completionSchema = schema?.trim() || undefined;
const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema;
if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesOracleCurrentSchema) {
return [];
}
const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`;
const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : "";
const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}${sessionCacheScope}`;
if (!completionColumnsCache.value[cacheKey]) {
await withCompletionInFlight(
`${cacheKey}:columns`,
async () => {
await ensureConnected(connectionId);
try {
const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, schema);
if (assistantColumns.length > 0) {
completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({
name: column.name,
data_type: column.dataType ?? "",
is_nullable: column.isNullable ?? true,
column_default: null,
is_primary_key: false,
extra: null,
comment: column.comment ?? null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
}));
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
return;
if (!usesOracleCurrentSchema) {
try {
const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, completionSchema);
if (assistantColumns.length > 0) {
completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({
name: column.name,
data_type: column.dataType ?? "",
is_nullable: column.isNullable ?? true,
column_default: null,
is_primary_key: false,
extra: null,
comment: column.comment ?? null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
}));
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
return;
}
} catch {
// Fall back to the existing metadata path below.
}
} catch {
// Fall back to the existing metadata path below.
}
const querySchema = metadataQuerySchema(connectionId, database, schema);
completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table);
const querySchema = usesOracleCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema);
completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table, undefined, usesOracleCurrentSchema ? context?.clientSessionId : undefined);
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "columns" },
@@ -4823,12 +4829,12 @@ export const useConnectionStore = defineStore("connection", () => {
const columns = completionColumnsCache.value[cacheKey].map((column) => ({
name: column.name,
table,
schema,
schema: completionSchema,
dataType: column.data_type,
isNullable: column.is_nullable,
comment: column.comment,
}));
indexCompletionColumns(connectionId, database, table, schema, columns);
if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, table, schema, columns);
return columns;
}
@@ -4874,8 +4880,8 @@ export const useConnectionStore = defineStore("connection", () => {
return listCompletionDatabases(connectionId);
}
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionColumn[]> {
return listCompletionColumns(connectionId, database, table, schema);
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }): Promise<SqlCompletionColumn[]> {
return listCompletionColumns(connectionId, database, table, schema, context);
}
function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionForeignKey[]> {
+24
View File
@@ -186,6 +186,25 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
return results;
}
function isOracleCurrentSchemaStatement(statement: string | undefined): boolean {
let remaining = statement?.trimStart() ?? "";
while (remaining) {
if (remaining.startsWith("--")) {
const newline = remaining.indexOf("\n");
remaining = newline < 0 ? "" : remaining.slice(newline + 1).trimStart();
continue;
}
if (remaining.startsWith("/*")) {
const end = remaining.indexOf("*/", 2);
if (end < 0) return false;
remaining = remaining.slice(end + 2).trimStart();
continue;
}
break;
}
return /^ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\s*=/i.test(remaining);
}
function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string, databaseType?: DatabaseType, sourceRange?: { from: number; to: number }): QueryResult {
result.sourceStatement = sourceStatement;
if (sourceRange) {
@@ -514,6 +533,7 @@ export const useQueryStore = defineStore("query", () => {
}
function queueTabSessionReset(tab: QueryTab) {
tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1;
const previousReset = pendingTabSessionResets.get(tab.id);
const reset = (async () => {
if (previousReset) await previousReset;
@@ -3412,6 +3432,7 @@ export const useQueryStore = defineStore("query", () => {
executionPromise = api.executeMulti(tab.connectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
}
const results = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset);
const successfulOracleSchemaChanges = effectiveDbType === "oracle" ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0;
if (hiddenPrimaryKeys.length > 0 && results.length === 1) {
const hiddenIndexes = hiddenResultColumnIndexes(results[0]!.columns, hiddenPrimaryKeys);
if (hiddenIndexes.length > 0) results[0]!.hidden_column_indexes = hiddenIndexes;
@@ -3428,6 +3449,9 @@ export const useQueryStore = defineStore("query", () => {
});
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (successfulOracleSchemaChanges > 0) {
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges;
}
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldAppendResult = !!options?.appendResult && !!current.result;
+2
View File
@@ -831,6 +831,8 @@ export interface QueryTab {
explainExecutionId?: string;
/** Per-run connection session for explain flows that require session state. */
explainClientSessionId?: string;
/** Invalidates tab-scoped completion metadata after session context changes. */
completionContextVersion?: number;
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard";
mqTenant?: string;
mqInitialTab?: "topics";
+30 -3
View File
@@ -4268,6 +4268,20 @@ async fn retry_metadata_connection<T, F, Fut>(
state: &AppState,
connection_id: &str,
database: Option<&str>,
operation: F,
) -> Result<T, String>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, String>>,
{
retry_metadata_connection_for_session(state, connection_id, database, None, operation).await
}
async fn retry_metadata_connection_for_session<T, F, Fut>(
state: &AppState,
connection_id: &str,
database: Option<&str>,
client_session_id: Option<&str>,
mut operation: F,
) -> Result<T, String>
where
@@ -4277,7 +4291,7 @@ where
let result = operation().await;
match result {
Err(error) if is_retryable_metadata_error(&error) => {
state.reconnect_pool(connection_id, database).await?;
state.reconnect_pool_for_session(connection_id, database, client_session_id).await?;
operation().await
}
_ => result,
@@ -4295,8 +4309,21 @@ pub async fn get_columns_core(
schema: &str,
table: &str,
) -> Result<Vec<db::ColumnInfo>, String> {
retry_metadata_connection(state, connection_id, Some(database), || async {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
get_columns_core_for_session(state, connection_id, database, schema, table, None).await
}
pub async fn get_columns_core_for_session(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
table: &str,
client_session_id: Option<&str>,
) -> Result<Vec<db::ColumnInfo>, String> {
retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async {
let pool_key = state
.get_or_create_pool_for_session(connection_id, Some(database), client_session_id)
.await?;
#[cfg(feature = "duckdb-bundled")]
let duckdb_attached_names = duckdb_attached_database_names(state, connection_id).await;
let db_config = connection_config(state, connection_id).await;
+11 -3
View File
@@ -22,6 +22,7 @@ pub struct SchemaQuery {
pub signature: Option<String>,
pub object_types: Option<String>,
pub apply_visible_filter: Option<bool>,
pub client_session_id: Option<String>,
}
pub async fn list_databases(
@@ -300,9 +301,16 @@ pub async fn list_columns(
.await
.map_err(AppError)?
} else {
dbx_core::schema::get_columns_core(&state.app, &q.connection_id, database, schema, table)
.await
.map_err(AppError)?
dbx_core::schema::get_columns_core_for_session(
&state.app,
&q.connection_id,
database,
schema,
table,
q.client_session_id.as_deref(),
)
.await
.map_err(AppError)?
};
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
+4
View File
@@ -107,6 +107,10 @@ test("Dameng default SYSDBA user remains selectable", () => {
assert.deepEqual(filterDatabaseNamesForConnection(["SYS", "SYSDBA", "SYSAUDITOR"], config({ db_type: "dameng" })), ["SYSDBA"]);
});
test("Oracle keeps an existing DIP user visible", () => {
assert.deepEqual(filterSchemaNamesForConnection(["DBX_TEST", "DIP", "SYSTEM"], config({ db_type: "oracle", database: "XE" }), "XE"), ["DBX_TEST", "DIP"]);
});
test("visible database selection is stale when connection target changes", () => {
const previous = config({ host: "db.internal", visible_databases: ["app"] });
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ host: "db.internal" })), false);
+10 -1
View File
@@ -292,12 +292,21 @@ pub async fn get_columns(
schema: String,
table: String,
catalog: Option<String>,
client_session_id: Option<String>,
) -> Result<Vec<db::ColumnInfo>, String> {
if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await {
return dbx_core::schema::get_doris_catalog_columns_core(&state, &connection_id, &catalog, &database, &table)
.await;
}
dbx_core::schema::get_columns_core(&state, &connection_id, &database, &schema, &table).await
dbx_core::schema::get_columns_core_for_session(
&state,
&connection_id,
&database,
&schema,
&table,
client_session_id.as_deref(),
)
.await
}
#[tauri::command]