test(editor): cover context menu routing payloads

This commit is contained in:
LRcoding 2026-07-15 20:53:22 +08:00 committed by GitHub
parent d3118256a4
commit 711d0ec57b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 484 additions and 197 deletions

View File

@ -159,6 +159,22 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(source.getSource().contains("CREATE MATERIALIZED VIEW"), source.getSource());
}
@Test
void readsViewSourceWithDbmsMetadataType() {
DamengAgent agent = new DamengAgent();
List<String> params = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
objectSourceConnection(params, "CREATE VIEW \"APP\".\"V_PROCESSPLAN\" AS SELECT 1 AS ID FROM DUAL")
);
ObjectSource source = agent.getObjectSource("APP", "V_PROCESSPLAN", "VIEW");
Assertions.assertEquals(List.of("VIEW", "V_PROCESSPLAN", "APP"), params);
Assertions.assertEquals("VIEW", source.getObject_type());
Assertions.assertTrue(source.getSource().contains("CREATE VIEW"), source.getSource());
}
@Test
void triggerMetadataDoesNotRequireOracleTriggerTypeColumn() {
DamengAgent agent = new DamengAgent();

View File

@ -39,7 +39,7 @@ import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresenta
import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext";
import { sqlObjectNavigationTableType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlObjectNavigationSourceKind, sqlObjectNavigationTableType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { uuid } from "@/lib/common/utils";
@ -86,6 +86,7 @@ import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition";
import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@ -104,6 +105,7 @@ const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/Upda
const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue"));
const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue"));
const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.vue"));
const QueryEditorObjectSourceDialog = defineAsyncComponent(() => import("@/components/objects/ObjectSourceDialog.vue"));
type AiAssistantHandle = {
triggerAction: (action: AiAction, instruction?: string) => void;
@ -135,6 +137,7 @@ const settingsPageTabOpen = ref(false);
const settingsInitialTab = ref("appearance");
const settingsInitialSection = ref<string | undefined>(undefined);
const showQueryEditorDdlDialog = ref(false);
const showQueryEditorObjectSourceDialog = ref(false);
const driverStoreTabOpen = ref(false);
const driverStoreActive = ref(false);
const driverStoreActiveTab = ref<"agent" | "jdbc" | "storage" | "runtime">("agent");
@ -160,6 +163,7 @@ const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
const queryEditorObjectSourceTarget = ref<{ connectionId: string; database: string; schema?: string; name: string; objectType: ObjectSourceKind; initialEditing: boolean } | null>(null);
const showSaveSqlDialog = ref(false);
const saveSqlName = ref("");
const saveSqlFolderId = ref("");
@ -352,6 +356,12 @@ const queryEditorDdlDatabaseType = computed(() => {
const queryEditorDdlDialect = computed(() => {
return codeMirrorSqlDialect(queryEditorDdlDatabaseType.value);
});
const queryEditorObjectSourceDatabaseType = computed(() => {
if (!queryEditorObjectSourceTarget.value?.connectionId) return undefined;
return effectiveDatabaseTypeForConnection(connectionStore.getConfig(queryEditorObjectSourceTarget.value.connectionId));
});
const queryEditorObjectSourceDialect = computed(() => codeMirrorSqlDialect(queryEditorObjectSourceDatabaseType.value));
const queryEditorObjectSourceFormatDialect = computed(() => sqlFormatDialectForDbType(queryEditorObjectSourceDatabaseType.value));
const connectionStats = computed(() => ({
total: connectionStore.connections.length,
connected: connectionStore.connectedIds.size,
@ -1204,9 +1214,20 @@ function tableTargetFromActiveTab(table: string | SqlObjectNavigationTarget) {
const tab = activeTab.value;
if (!tab) return null;
const connectionId = tab.connectionId;
if (typeof table !== "string") {
// Structured targets already separate qualifiers; reparsing would corrupt quoted object names that contain dots.
return {
connectionId,
database: table.database || tab.database,
schema: table.schema || tab.schema,
tableName: table.name,
tableType: table.type ? sqlObjectNavigationTableType(table) : undefined,
};
}
let database = tab.database;
let schema = typeof table === "string" ? tab.schema : table.schema || tab.schema;
const tableName = typeof table === "string" ? table : table.name;
let schema = tab.schema;
const tableName = table;
const parts = tableName.split(".").filter(Boolean);
const rawTableName = parts[parts.length - 1] || tableName;
@ -1223,15 +1244,16 @@ function tableTargetFromActiveTab(table: string | SqlObjectNavigationTarget) {
}
}
return { connectionId, database, schema, tableName: rawTableName, tableType: typeof table === "string" ? undefined : sqlObjectNavigationTableType(table) };
return { connectionId, database, schema, tableName: rawTableName, tableType: undefined };
}
async function onClickTable(table: SqlObjectNavigationTarget) {
const target = tableTargetFromActiveTab(table);
if (!target) return;
if (table.type === "view") {
const objectType = sqlObjectNavigationSourceKind(table);
if (objectType) {
// Definition navigation for views must not run the view query, which may be expensive or have side effects upstream.
queryEditorDdlTarget.value = { ...target, objectType: "VIEW" };
queryEditorDdlTarget.value = { ...target, objectType };
showQueryEditorDdlDialog.value = true;
return;
}
@ -1242,8 +1264,8 @@ async function onClickTable(table: SqlObjectNavigationTarget) {
}
}
async function onViewTableData(tableName: string) {
const target = tableTargetFromActiveTab(tableName);
async function onViewTableData(table: SqlObjectNavigationTarget) {
const target = tableTargetFromActiveTab(table);
if (!target) return;
try {
await openTableTarget(target);
@ -1252,19 +1274,41 @@ async function onViewTableData(tableName: string) {
}
}
function onViewTableDdl(tableName: string) {
const target = tableTargetFromActiveTab(tableName);
function onViewTableDdl(table: SqlObjectNavigationTarget) {
const target = tableTargetFromActiveTab(table);
if (!target) return;
queryEditorDdlTarget.value = target;
queryEditorDdlTarget.value = { ...target, objectType: sqlObjectNavigationSourceKind(table) };
showQueryEditorDdlDialog.value = true;
}
function onEditTableStructure(tableName: string) {
const target = tableTargetFromActiveTab(tableName);
if (!target) return;
function onEditTableStructure(table: SqlObjectNavigationTarget) {
const target = tableTargetFromActiveTab(table);
// Keep view-like objects out of the table editor even if a stale menu dispatches this event.
if (!target || sqlObjectNavigationSourceKind(table)) return;
queryStore.openTableStructure(target.connectionId, target.database, target.schema, target.tableName);
}
async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: boolean) {
const target = tableTargetFromActiveTab(table);
const objectType = sqlObjectNavigationSourceKind(table);
if (!target || !objectType) return;
try {
await connectionStore.ensureConnected(target.connectionId);
connectionStore.activeConnectionId = target.connectionId;
queryEditorObjectSourceTarget.value = { connectionId: target.connectionId, database: target.database, schema: target.schema, name: target.tableName, objectType, initialEditing };
showQueryEditorObjectSourceDialog.value = true;
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
function onQueryEditorObjectSourceSaved() {
const target = queryEditorObjectSourceTarget.value;
if (!target) return;
connectionStore.invalidateCompletionCache(target.connectionId, target.database);
contentAreaRef.value?.refreshQueryEditorCompletionCache();
}
async function changeActiveConnection(connectionId: string) {
const tab = activeTab.value;
if (!tab) return;
@ -2004,6 +2048,7 @@ onUnmounted(() => {
@view-table-data="onViewTableData"
@edit-table-structure="onEditTableStructure"
@view-table-ddl="onViewTableDdl"
@open-object-source="onOpenObjectSource"
@open-object-table="
(target) =>
activeTab &&
@ -2211,6 +2256,20 @@ onUnmounted(() => {
:database-type="queryEditorDdlDatabaseType"
:dialect="queryEditorDdlDialect"
/>
<QueryEditorObjectSourceDialog
v-if="queryEditorObjectSourceTarget"
v-model:open="showQueryEditorObjectSourceDialog"
:connection-id="queryEditorObjectSourceTarget.connectionId"
:database="queryEditorObjectSourceTarget.database"
:schema="queryEditorObjectSourceTarget.schema"
:name="queryEditorObjectSourceTarget.name"
:object-type="queryEditorObjectSourceTarget.objectType"
:initial-editing="queryEditorObjectSourceTarget.initialEditing"
:database-type="queryEditorObjectSourceDatabaseType"
:dialect="queryEditorObjectSourceDialect"
:format-dialect="queryEditorObjectSourceFormatDialect"
@saved="onQueryEditorObjectSourceSaved"
/>
</TooltipProvider>
</div>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { CaseLower, CaseUpper, FileCode, PencilRuler, Play, Copy, Sparkles, Table2, TextSelect } from "@lucide/vue";
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, Sparkles, Table2, TextSelect } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { EditorView as EditorViewType } from "@codemirror/view";
@ -40,7 +40,7 @@ import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget }
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
import {
DBX_TABLE_REFERENCE_MIME,
@ -65,7 +65,7 @@ import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorP
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport, tableReferenceKey, type SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
@ -105,9 +105,10 @@ const emit = defineEmits<{
execute: [source: SqlExecutionOverride];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [tableName: string];
viewTableDdl: [tableName: string];
editTableStructure: [tableName: string];
viewTableData: [target: SqlObjectNavigationTarget];
viewTableDdl: [target: SqlObjectNavigationTarget];
editTableStructure: [target: SqlObjectNavigationTarget];
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
closeColumnPanel: [];
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }];
@ -188,7 +189,7 @@ const isGestureZooming = ref(false);
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const selectedSql = ref("");
const executableSql = ref("");
const contextTableName = ref<string | null>(null);
const contextObjectTarget = ref<SqlObjectNavigationTarget | null>(null);
const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0);
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
@ -285,7 +286,7 @@ function editorThemeAppearance() {
}
// Completion cache
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
let cachedTables: SqlCompletionTable[] = [];
let cachedCompletionObjects: SqlCompletionObject[] = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
@ -554,7 +555,28 @@ function syncContextMenuState(currentView: EditorViewType) {
function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEvent) {
syncContextMenuState(currentView);
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
contextTableName.value = pos == null ? null : qualifiedTableNameAtSqlPosition(currentView.state.doc.toString(), pos);
if (pos == null) {
contextObjectTarget.value = null;
return;
}
const sql = currentView.state.doc.toString();
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "", database: props.database ?? "", schema: props.schema, databaseType: props.databaseType, sql, position: pos });
contextObjectTarget.value = candidate ? { name: candidate.tableName, database: candidate.database, schema: candidate.schema } : null;
return;
}
const parsedCandidate = queryTableCandidateAtSqlPosition({ connectionId: props.connectionId, database: props.database, schema: props.schema, databaseType: props.databaseType, sql, position: pos });
if (!parsedCandidate) {
contextObjectTarget.value = null;
return;
}
// Right-click must stay instant: resolve from completion/tree caches and keep the legacy table fallback when metadata is unavailable.
const candidate = resolveQueryContextCandidateDatabase(parsedCandidate, connectionStore.lookupLocalCompletionDatabases(parsedCandidate.connectionId, parsedCandidate.database, MAX_COMPLETION_TABLES));
const tables = connectionStore.lookupLocalCompletionTables(candidate.connectionId, candidate.database, candidate.tableName, MAX_COMPLETION_TABLES, candidate.schema);
contextObjectTarget.value = resolveQueryContextObjectTarget(candidate, tables);
}
function focusEditor() {
@ -849,22 +871,40 @@ async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
return true;
}
function openTableFromContextMenu() {
if (!contextTableName.value) return;
emit("viewTableData", contextTableName.value);
function emitContextObjectAction(action: QueryContextObjectAction) {
if (!contextObjectTarget.value) return;
const route = queryContextObjectRoute(action, contextObjectTarget.value);
switch (route.event) {
case "viewTableData":
emit("viewTableData", route.payload[0]);
break;
case "editTableStructure":
emit("editTableStructure", route.payload[0]);
break;
case "openObjectSource":
emit("openObjectSource", route.payload[0], route.payload[1]);
break;
case "viewTableDdl":
emit("viewTableDdl", route.payload[0]);
break;
}
focusEditor();
}
function editTableStructureFromContextMenu() {
if (!contextTableName.value) return;
emit("editTableStructure", contextTableName.value);
focusEditor();
}
function openTableDdlFromContextMenu() {
if (!contextTableName.value) return;
emit("viewTableDdl", contextTableName.value);
focusEditor();
function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuItem {
const disabled = !contextObjectTarget.value;
switch (action) {
case "view-data":
return { label: t("contextMenu.viewData"), action: () => emitContextObjectAction(action), disabled, icon: Table2 };
case "edit-table-structure":
return { label: t("contextMenu.editStructure"), action: () => emitContextObjectAction(action), disabled, icon: PencilRuler };
case "edit-view":
return { label: t("contextMenu.editView"), action: () => emitContextObjectAction(action), disabled, icon: Pencil };
case "view-source":
return { label: t("contextMenu.viewSource"), action: () => emitContextObjectAction(action), disabled, icon: Code2 };
case "view-ddl":
return { label: t("contextMenu.viewDdl"), action: () => emitContextObjectAction(action), disabled, icon: FileCode };
}
}
function executableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) {
@ -917,24 +957,7 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
shortcut: shortcuts.executeSql,
},
]),
{
label: t("contextMenu.viewData"),
action: openTableFromContextMenu,
disabled: !contextTableName.value,
icon: Table2,
},
{
label: t("contextMenu.editStructure"),
action: editTableStructureFromContextMenu,
disabled: !contextTableName.value,
icon: PencilRuler,
},
{
label: t("contextMenu.viewDdl"),
action: openTableDdlFromContextMenu,
disabled: !contextTableName.value,
icon: FileCode,
},
...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem),
{ label: "", separator: true },
{
label: t("editor.contextMenu.copySelection"),
@ -1230,7 +1253,7 @@ function completionTablesMatch(left: { name: string; schema?: string | null }, r
return left.schema.toLowerCase() === right.schema.toLowerCase();
}
async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promise<{ name: string; schema?: string; type?: "table" | "view" } | null> {
async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promise<SqlCompletionTable | null> {
if (!props.connectionId || props.database == null) return null;
const target = completionMetadataTarget(table);
if (!target) return null;
@ -1373,7 +1396,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
if (!table && !usesLocalOnlyCompletionMetadata()) {
const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema);
cachedTables = [...cachedTables, ...hoverTables];
cachedTables = mergeCompletionTables(cachedTables, hoverTables);
table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
}
if (table && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
@ -2332,7 +2355,9 @@ function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlComp
indexes.set(key, merged.length);
merged.push(table);
} else {
merged[index] = { ...merged[index], ...table };
const existing = merged[index];
// Preserve the more specific tree type if an older metadata endpoint reports a materialized view as VIEW.
merged[index] = { ...existing, ...table, type: mergeSqlObjectNavigationType(existing.type, table.type) };
}
}
return merged;
@ -2644,7 +2669,7 @@ function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCo
return merged;
}
async function refreshCompletionCache() {
function refreshCompletionCache() {
cachedTables = [];
cachedCompletionObjects = [];
cachedColumnsByTable.clear();
@ -3673,7 +3698,7 @@ function scrollCursorIntoView() {
});
}
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange });
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange, refreshCompletionCache });
</script>
<template>

View File

@ -150,9 +150,10 @@ const emit = defineEmits<{
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [sql: string];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [tableName: string];
viewTableDdl: [tableName: string];
editTableStructure: [tableName: string];
viewTableData: [target: SqlObjectNavigationTarget];
viewTableDdl: [target: SqlObjectNavigationTarget];
editTableStructure: [target: SqlObjectNavigationTarget];
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
openObjectTable: [target: { tableName: string; schema?: string; tableType?: string; catalog?: string }];
objectSchemaChange: [schema: string | undefined];
objectBrowserViewportChange: [tabId: string, viewport: ObjectBrowserViewport];
@ -656,16 +657,20 @@ function onHandleClickTable(target: SqlObjectNavigationTarget) {
emit("clickTable", target);
}
function onHandleViewTableData(tableName: string) {
emit("viewTableData", tableName);
function onHandleViewTableData(target: SqlObjectNavigationTarget) {
emit("viewTableData", target);
}
function onHandleViewTableDdl(tableName: string) {
emit("viewTableDdl", tableName);
function onHandleViewTableDdl(target: SqlObjectNavigationTarget) {
emit("viewTableDdl", target);
}
function onHandleEditTableStructure(tableName: string) {
emit("editTableStructure", tableName);
function onHandleEditTableStructure(target: SqlObjectNavigationTarget) {
emit("editTableStructure", target);
}
function onHandleOpenObjectSource(target: SqlObjectNavigationTarget, initialEditing: boolean) {
emit("openObjectSource", target, initialEditing);
}
function onHandleCloseColumnPanel() {
@ -683,6 +688,12 @@ function focusSearch(): boolean {
return dataGridRef.value?.focusSearch() ?? false;
}
function refreshQueryEditorCompletionCache(): boolean {
if (props.activeTab.mode !== "query" || !queryEditorRef.value) return false;
queryEditorRef.value.refreshCompletionCache();
return true;
}
function refreshData(): boolean {
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
@ -773,7 +784,7 @@ function applyTableStructureChanges() {
return tableStructureEditorRef.value?.applyChanges() ?? Promise.resolve(false);
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition, applyTableStructureChanges });
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition, applyTableStructureChanges });
</script>
<template>
@ -821,6 +832,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
@view-table-data="onHandleViewTableData"
@edit-table-structure="onHandleEditTableStructure"
@view-table-ddl="onHandleViewTableDdl"
@open-object-source="onHandleOpenObjectSource"
@click-column="onHandleClickColumn"
@close-column-panel="onHandleCloseColumnPanel"
/>

View File

@ -42,6 +42,22 @@ describe("completionTreeIndex", () => {
database: "hive",
schema: "sales_analytics",
},
{
id: "conn:hive:sales_analytics:__tables:daily_revenue_view",
label: "daily_revenue_view",
type: "view",
connectionId: "conn",
database: "hive",
schema: "sales_analytics",
},
{
id: "conn:hive:sales_analytics:__tables:daily_revenue_mv",
label: "daily_revenue_mv",
type: "materialized_view",
connectionId: "conn",
database: "hive",
schema: "sales_analytics",
},
],
},
],
@ -53,7 +69,12 @@ describe("completionTreeIndex", () => {
];
expect(completionSchemasFromTree(tree, "conn", "hive")).toEqual(["sales_analytics"]);
expect(completionTablesFromTree(tree, "conn", "hive", "sales_analytics")).toEqual([{ name: "daily_revenue", schema: "sales_analytics", type: "table" }]);
expect(completionTablesFromTree(tree, "conn", "hive")).toEqual([{ name: "daily_revenue", schema: "sales_analytics", type: "table" }]);
const expected = [
{ name: "daily_revenue", schema: "sales_analytics", type: "table" },
{ name: "daily_revenue_view", schema: "sales_analytics", type: "view" },
{ name: "daily_revenue_mv", schema: "sales_analytics", type: "materialized_view" },
];
expect(completionTablesFromTree(tree, "conn", "hive", "sales_analytics")).toEqual(expected);
expect(completionTablesFromTree(tree, "conn", "hive")).toEqual(expected);
});
});

View File

@ -1,5 +1,17 @@
import { describe, expect, it } from "vitest";
import { extractIdentifierAt, extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTableType, sqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import {
extractIdentifierAt,
extractIdentifierDetailsAt,
isSqlKeyword,
matchTable,
mergeSqlObjectNavigationType,
splitQualifiedIdentifier,
sqlObjectHoverDetail,
sqlObjectNavigationSourceKind,
sqlObjectNavigationTableType,
sqlObjectNavigationTarget,
sqlObjectNavigationTypeFromTableType,
} from "@/lib/sql/sqlNavigation";
describe("extractIdentifierAt", () => {
it("extracts unquoted qualified identifiers", () => {
@ -77,8 +89,9 @@ describe("matchTable", () => {
describe("SQL object navigation metadata", () => {
it("preserves view type and schema for command-click navigation", () => {
expect(sqlObjectNavigationTarget({ name: "active_users", schema: "dbo", type: "view" })).toEqual({
expect(sqlObjectNavigationTarget({ name: "active_users", database: "app", schema: "dbo", type: "view" })).toEqual({
name: "active_users",
database: "app",
schema: "dbo",
type: "view",
});
@ -86,11 +99,24 @@ describe("SQL object navigation metadata", () => {
it("uses the object type in hover details", () => {
expect(sqlObjectHoverDetail({ name: "active_users", schema: "dbo", type: "view" })).toBe("view in dbo");
expect(sqlObjectHoverDetail({ name: "active_users_mv", schema: "dbo", type: "materialized_view" })).toBe("materialized view in dbo");
expect(sqlObjectHoverDetail({ name: "users", schema: "dbo", type: "table" })).toBe("table in dbo");
});
it("maps navigation types to table metadata types", () => {
expect(sqlObjectNavigationTableType({ name: "active_users", type: "view" })).toBe("VIEW");
expect(sqlObjectNavigationTableType({ name: "active_users_mv", type: "materialized_view" })).toBe("MATERIALIZED_VIEW");
expect(sqlObjectNavigationTableType({ name: "users", type: "table" })).toBe("TABLE");
expect(sqlObjectNavigationSourceKind({ name: "active_users", type: "view" })).toBe("VIEW");
expect(sqlObjectNavigationSourceKind({ name: "active_users_mv", type: "materialized_view" })).toBe("MATERIALIZED_VIEW");
expect(sqlObjectNavigationSourceKind({ name: "users", type: "table" })).toBeUndefined();
});
it("normalizes relation metadata without collapsing materialized views", () => {
expect(sqlObjectNavigationTypeFromTableType("BASE TABLE")).toBe("table");
expect(sqlObjectNavigationTypeFromTableType("VIEW")).toBe("view");
expect(sqlObjectNavigationTypeFromTableType("materialized view")).toBe("materialized_view");
expect(mergeSqlObjectNavigationType("view", "materialized_view")).toBe("materialized_view");
expect(mergeSqlObjectNavigationType("table", "view")).toBe("view");
});
});

View File

@ -25,7 +25,7 @@ export function completionTablesFromTree(nodes: readonly TreeNode[], connectionI
tables.push({
name: node.tableName || node.label,
schema: node.schema,
type: node.type === "view" || node.type === "materialized_view" ? "view" : "table",
type: node.type === "materialized_view" ? "materialized_view" : node.type === "view" ? "view" : "table",
});
});
return dedupeCompletionTables(tables);

View File

@ -1,6 +1,7 @@
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { isSqlKeyword } from "@/lib/sql/sqlNavigation";
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate {
@ -10,99 +11,28 @@ export interface QueryCursorTableCandidate {
tableName: string;
}
export interface IdentifierPart {
value: string;
quoted: boolean;
export interface QueryTableCandidateAtPositionInput {
connectionId: string;
database: string;
schema?: string;
databaseType?: DatabaseType;
sql: string;
position: number;
}
function identifierChar(ch: string | undefined): boolean {
return !!ch && (/[A-Za-z0-9_$."`-]/.test(ch) || ch === "[" || ch === "]");
}
export type QueryContextObjectAction = "view-data" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
function readIdentifierWindow(sql: string, pos: number): string {
const clamped = Math.max(0, Math.min(pos, sql.length));
let from = clamped;
let to = clamped;
export type QueryContextObjectRoute =
| { event: "viewTableData"; payload: [target: SqlObjectNavigationTarget] }
| { event: "editTableStructure"; payload: [target: SqlObjectNavigationTarget] }
| { event: "openObjectSource"; payload: [target: SqlObjectNavigationTarget, initialEditing: boolean] }
| { event: "viewTableDdl"; payload: [target: SqlObjectNavigationTarget] };
if (!identifierChar(sql[from]) && identifierChar(sql[from - 1])) {
from -= 1;
to = from + 1;
}
if (!identifierChar(sql[from])) return "";
while (from > 0 && identifierChar(sql[from - 1])) from -= 1;
while (to < sql.length && identifierChar(sql[to])) to += 1;
return sql.slice(from, to).replace(/^\.+|\.+$/g, "");
}
function splitQualifiedIdentifier(text: string): IdentifierPart[] {
const parts: IdentifierPart[] = [];
let current = "";
let quoted = false;
let quote: "`" | '"' | "]" | null = null;
function pushPart() {
const value = current.trim();
if (value) parts.push({ value, quoted });
current = "";
quoted = false;
}
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (quote) {
if (quote === "]" && ch === "]") {
if (text[i + 1] === "]") {
current += "]";
i += 1;
} else {
quote = null;
}
continue;
}
if ((quote === "`" || quote === '"') && ch === quote) {
if (text[i + 1] === quote) {
current += ch;
i += 1;
} else {
quote = null;
}
continue;
}
current += ch;
continue;
}
if (ch === ".") {
pushPart();
continue;
}
if (ch === "`" || ch === '"') {
quoted = true;
quote = ch;
continue;
}
if (ch === "[") {
quoted = true;
quote = "]";
continue;
}
current += ch;
}
pushPart();
return parts;
}
export function extractQualifiedIdentifierPartsAt(sql: string, pos: number): IdentifierPart[] {
const text = readIdentifierWindow(sql, pos);
if (!text) return [];
const parts = splitQualifiedIdentifier(text);
export function extractQualifiedIdentifierPartsAt(sql: string, pos: number) {
let parts = extractIdentifierPartsAt(sql, pos);
// CodeMirror can report the boundary immediately after the clicked token;
// retain the legacy behavior that treats that boundary as part of the identifier.
if (parts.length === 0 && pos > 0) parts = extractIdentifierPartsAt(sql, pos - 1);
const last = parts[parts.length - 1];
if (!last || (!last.quoted && isSqlKeyword(last.value))) return [];
return parts;
@ -112,18 +42,29 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null;
const cursor = tab.editorSelection?.head ?? tab.editorSelection?.anchor ?? tab.sql.length;
const parts = extractQualifiedIdentifierPartsAt(tab.sql, cursor).map((part) => part.value);
return queryTableCandidateAtSqlPosition({
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
databaseType,
sql: tab.sql,
position: cursor,
});
}
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
if (parts.length === 0) return null;
const tableName = parts[parts.length - 1];
let database = tab.database;
let schema = tab.schema;
let database = input.database;
let schema = input.schema;
if (parts.length >= 3) {
database = parts[parts.length - 3];
schema = parts[parts.length - 2];
} else if (parts.length === 2) {
if (databaseType && !isSchemaAware(databaseType) && !isSingleDatabase(databaseType)) {
if (input.databaseType && !isSchemaAware(input.databaseType) && !isSingleDatabase(input.databaseType)) {
database = parts[0];
schema = undefined;
} else {
@ -131,7 +72,46 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
}
}
return { connectionId: tab.connectionId, database, schema, tableName };
return { connectionId: input.connectionId, database, schema, tableName };
}
export function resolveQueryContextCandidateDatabase(candidate: QueryCursorTableCandidate, databases: readonly string[]): QueryCursorTableCandidate {
const database = databases.find((name) => sameIdentifier(name, candidate.database));
return database && database !== candidate.database ? { ...candidate, database } : candidate;
}
export function resolveQueryContextObjectTarget(candidate: QueryCursorTableCandidate, tables: readonly SqlCompletionTable[]): SqlObjectNavigationTarget {
const nameMatches = tables.filter((table) => sameIdentifier(table.name, candidate.tableName));
const match = candidate.schema ? (nameMatches.find((table) => sameIdentifier(table.schema, candidate.schema)) ?? nameMatches.find((table) => !table.schema)) : nameMatches[0];
return sqlObjectNavigationTarget({
name: match?.name ?? candidate.tableName,
database: candidate.database,
schema: match?.schema ?? candidate.schema,
type: match?.type,
});
}
export function queryContextObjectActions(type?: SqlObjectNavigationType): QueryContextObjectAction[] {
if (type === "view" || type === "materialized_view") {
return ["view-data", "edit-view", "view-source", "view-ddl"];
}
// Unknown metadata preserves the historical table actions instead of disabling existing entry points.
return ["view-data", "edit-table-structure", "view-ddl"];
}
export function queryContextObjectRoute(action: QueryContextObjectAction, target: SqlObjectNavigationTarget): QueryContextObjectRoute {
switch (action) {
case "view-data":
return { event: "viewTableData", payload: [target] };
case "edit-table-structure":
return { event: "editTableStructure", payload: [target] };
case "edit-view":
return { event: "openObjectSource", payload: [target, true] };
case "view-source":
return { event: "openObjectSource", payload: [target, false] };
case "view-ddl":
return { event: "viewTableDdl", payload: [target] };
}
}
export function qualifiedTableNameAtSqlPosition(sql: string, pos: number): string | null {

View File

@ -2,6 +2,7 @@ import { Cassandra, MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, SQLite, StandardS
import type { DatabaseType, SqlSnippet } from "@/types/database";
import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { CLOUDFLARE_D1_COMMON_FUNCTION_NAMES } from "@/lib/sql/cloudflareD1";
import type { SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
const SQL_KEYWORDS = [
"SELECT",
@ -1156,7 +1157,7 @@ function sqlAliasKeywordWords(...sources: Array<string | undefined>): string[] {
export interface SqlCompletionTable {
name: string;
schema?: string;
type?: "table" | "view";
type?: SqlObjectNavigationType;
detail?: string;
applyName?: string;
boost?: number;

View File

@ -2,6 +2,8 @@
* Utilities for SQL identifier navigation (Ctrl/Cmd + click on table/column names).
*/
import type { ObjectSourceKind } from "@/types/database";
const SQL_KEYWORDS_SET = new Set([
"select",
"from",
@ -128,29 +130,60 @@ export interface ExtractedSqlIdentifier {
quoted: boolean;
}
export interface ExtractedSqlIdentifierPart {
value: string;
quoted: boolean;
}
export type SqlObjectNavigationType = "table" | "view" | "materialized_view";
export interface SqlObjectNavigationTarget {
name: string;
database?: string;
schema?: string;
type?: "table" | "view";
type?: SqlObjectNavigationType;
}
export function sqlObjectNavigationTarget(table: SqlObjectNavigationTarget): SqlObjectNavigationTarget {
return {
name: table.name,
...(table.database ? { database: table.database } : {}),
...(table.schema ? { schema: table.schema } : {}),
...(table.type ? { type: table.type } : {}),
};
}
export function sqlObjectHoverDetail(table: SqlObjectNavigationTarget): string {
const objectType = table.type === "view" ? "view" : "table";
const objectType = table.type === "materialized_view" ? "materialized view" : table.type === "view" ? "view" : "table";
return table.schema ? `${objectType} in ${table.schema}` : objectType;
}
export function sqlObjectNavigationTableType(table: SqlObjectNavigationTarget): "TABLE" | "VIEW" {
export function sqlObjectNavigationTableType(table: SqlObjectNavigationTarget): "TABLE" | "VIEW" | "MATERIALIZED_VIEW" {
if (table.type === "materialized_view") return "MATERIALIZED_VIEW";
return table.type === "view" ? "VIEW" : "TABLE";
}
export function sqlObjectNavigationSourceKind(table: SqlObjectNavigationTarget): ObjectSourceKind | undefined {
const type = sqlObjectNavigationTableType(table);
return type === "TABLE" ? undefined : type;
}
export function sqlObjectNavigationTypeFromTableType(tableType: string | null | undefined): SqlObjectNavigationType {
const normalized = tableType
?.trim()
.toUpperCase()
.replace(/[\s-]+/g, "_");
if (normalized === "MATERIALIZED_VIEW") return "materialized_view";
if (normalized === "VIEW") return "view";
return "table";
}
export function mergeSqlObjectNavigationType(left?: SqlObjectNavigationType, right?: SqlObjectNavigationType): SqlObjectNavigationType | undefined {
if (left === "materialized_view" || right === "materialized_view") return "materialized_view";
if (left === "view" || right === "view") return "view";
return left ?? right;
}
function isIdentifierChar(char: string | undefined): boolean {
return !!char && /^[A-Za-z0-9_$]$/.test(char);
}
@ -213,12 +246,12 @@ function identifierSearchBounds(doc: string, pos: number): { start: number; end:
return { start, end };
}
/** Extract identifier and quote metadata at position `pos` in the document. */
export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedSqlIdentifier | null {
if (pos < 0 || pos > doc.length) return null;
/** Extract qualified identifier parts and per-part quote metadata at position `pos`. */
export function extractIdentifierPartsAt(doc: string, pos: number): ExtractedSqlIdentifierPart[] {
if (pos < 0 || pos > doc.length) return [];
const clickPos = pos === doc.length ? pos - 1 : pos;
if (clickPos < 0) return null;
if (clickPos < 0) return [];
const bounds = identifierSearchBounds(doc, clickPos);
let index = bounds.start;
@ -226,10 +259,7 @@ export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedS
const parsed = parseQualifiedIdentifier(doc, index);
if (parsed) {
if (clickPos >= parsed.start && clickPos < parsed.end) {
return {
identifier: parsed.parts.map((part) => part.value).join("."),
quoted: parsed.parts.some((part) => part.quoted),
};
return parsed.parts.map((part) => ({ value: part.value, quoted: part.quoted }));
}
index = Math.max(parsed.end, index + 1);
continue;
@ -237,7 +267,17 @@ export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedS
index += 1;
}
return null;
return [];
}
/** Extract identifier and quote metadata at position `pos` in the document. */
export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedSqlIdentifier | null {
const parts = extractIdentifierPartsAt(doc, pos);
if (parts.length === 0) return null;
return {
identifier: parts.map((part) => part.value).join("."),
quoted: parts.some((part) => part.quoted),
};
}
/** Extract identifier at position `pos` in the document. */

View File

@ -38,6 +38,7 @@ import {
type DropPosition,
} from "@/lib/sidebar/sidebarLayout";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
@ -1180,15 +1181,10 @@ export const useConnectionStore = defineStore("connection", () => {
return tables.map((table) => ({
name: table.name,
schema,
type: isViewLikeTableType(table.table_type) ? "view" : "table",
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
}
function isViewLikeTableType(tableType: string): boolean {
const normalized = tableType.toUpperCase().replace(/[\s-]+/g, "_");
return normalized === "VIEW" || normalized === "MATERIALIZED_VIEW";
}
function sameSidebarObjectName(left: string | undefined, right: string | undefined): boolean {
return (left || "").toLowerCase() === (right || "").toLowerCase();
}
@ -4090,7 +4086,7 @@ export const useConnectionStore = defineStore("connection", () => {
const table: SqlCompletionTable = {
name: candidate.name,
schema: candidate.schema ?? undefined,
type: candidate.kind === "view" ? "view" : "table",
type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind),
};
if (!withOracleMetadata) return table;
return {
@ -4501,7 +4497,7 @@ export const useConnectionStore = defineStore("connection", () => {
results = tables.map((table) => ({
name: table.name,
schema,
type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const),
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
} else {
results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit);
@ -4520,7 +4516,7 @@ export const useConnectionStore = defineStore("connection", () => {
results = tables.map((table) => ({
name: table.name,
schema,
type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const),
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
} catch {
results = [];
@ -4541,7 +4537,7 @@ export const useConnectionStore = defineStore("connection", () => {
completionTablesCache.value[cacheKey] = tables.map((table) => ({
name: table.name,
schema,
type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const),
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
} else {
completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit);
@ -4557,7 +4553,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
completionTablesCache.value[cacheKey] = tables.map((table) => ({
name: table.name,
type: isViewLikeTableType(table.table_type) ? ("view" as const) : ("table" as const),
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey];
indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey]);
@ -4579,12 +4575,18 @@ export const useConnectionStore = defineStore("connection", () => {
}
function dedupeCompletionTables(tables: SqlCompletionTable[]): SqlCompletionTable[] {
const seen = new Set<string>();
const indexByKey = new Map<string, number>();
const deduped: SqlCompletionTable[] = [];
for (const table of tables) {
const key = `${table.schema ?? ""}.${table.name}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const existingIndex = indexByKey.get(key);
if (existingIndex != null) {
const existing = deduped[existingIndex];
// Loaded tree metadata can distinguish materialized views even when an older completion endpoint only reports VIEW.
deduped[existingIndex] = { ...table, ...existing, type: mergeSqlObjectNavigationType(existing.type, table.type) };
continue;
}
indexByKey.set(key, deduped.length);
deduped.push(table);
}
return deduped;

View File

@ -1,6 +1,16 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { extractQualifiedIdentifierPartsAt, findLoadedTableTargetForCandidate, qualifiedTableNameAtSqlPosition, queryContextTargetFromCandidate, queryCursorTableCandidate } from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts";
import {
extractQualifiedIdentifierPartsAt,
findLoadedTableTargetForCandidate,
qualifiedTableNameAtSqlPosition,
queryContextObjectActions,
queryContextTargetFromCandidate,
queryCursorTableCandidate,
queryTableCandidateAtSqlPosition,
resolveQueryContextCandidateDatabase,
resolveQueryContextObjectTarget,
} from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts";
import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts";
function queryTab(sql: string, head: number, schema = "public"): QueryTab {
@ -38,6 +48,7 @@ test("resolves the table name at a context-menu position", () => {
assert.equal(qualifiedTableNameAtSqlPosition(sql, sql.indexOf("users") + 2), "reporting.users");
assert.equal(qualifiedTableNameAtSqlPosition("select * from users", "select * from users".length), "users");
assert.equal(qualifiedTableNameAtSqlPosition(sql, sql.indexOf(" where")), "reporting.users");
assert.equal(qualifiedTableNameAtSqlPosition(sql, sql.indexOf("where") + 1), null);
});
@ -63,6 +74,74 @@ test("builds database-qualified candidates for multi-database non-schema engines
});
});
test("builds three-part candidates at an explicit context-menu position", () => {
const sql = 'select * from "warehouse"."reporting"."Daily Sales"';
assert.deepEqual(queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "app", schema: "public", databaseType: "postgres", sql, position: sql.indexOf("Daily") + 2 }), {
connectionId: "conn-1",
database: "warehouse",
schema: "reporting",
tableName: "Daily Sales",
});
});
test("parses escaped quotes inside qualified identifiers", () => {
const sql = 'select * from "warehouse"."reporting"."Daily ""Sales"""';
assert.deepEqual(queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "app", schema: "public", databaseType: "postgres", sql, position: sql.indexOf("Sales") }), {
connectionId: "conn-1",
database: "warehouse",
schema: "reporting",
tableName: 'Daily "Sales"',
});
});
test("resolves database qualifiers case-insensitively from local metadata", () => {
const candidate = { connectionId: "conn-1", database: "analytics", schema: undefined, tableName: "events" };
assert.deepEqual(resolveQueryContextCandidateDatabase(candidate, ["App", "Analytics"]), {
...candidate,
database: "Analytics",
});
assert.equal(resolveQueryContextCandidateDatabase(candidate, []), candidate);
});
test("resolves cached relation types and actual casing for context-menu targets", () => {
const candidate = { connectionId: "conn-1", database: "app", schema: "REPORTING", tableName: "daily_sales" };
assert.deepEqual(
resolveQueryContextObjectTarget(candidate, [
{ name: "daily_sales", schema: "archive", type: "table" },
{ name: "Daily_Sales", schema: "reporting", type: "materialized_view" },
]),
{
name: "Daily_Sales",
database: "app",
schema: "reporting",
type: "materialized_view",
},
);
});
test("preserves table actions when context-menu metadata is unavailable", () => {
const candidate = { connectionId: "conn-1", database: "app", schema: "public", tableName: "unknown_relation" };
assert.deepEqual(resolveQueryContextObjectTarget(candidate, []), {
name: "unknown_relation",
database: "app",
schema: "public",
});
assert.deepEqual(queryContextObjectActions(undefined), ["view-data", "edit-table-structure", "view-ddl"]);
});
test("uses source actions for views and materialized views", () => {
const expected = ["view-data", "edit-view", "view-source", "view-ddl"];
assert.deepEqual(queryContextObjectActions("view"), expected);
assert.deepEqual(queryContextObjectActions("materialized_view"), expected);
assert.deepEqual(queryContextObjectActions("table"), ["view-data", "edit-table-structure", "view-ddl"]);
});
test("falls back to the candidate database and schema when no table is loaded", () => {
const tab = queryTab("select * from reporting.missing", "select * from reporting.missing".length);
const candidate = queryCursorTableCandidate(tab, "postgres");

View File

@ -0,0 +1,26 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { queryContextObjectRoute } from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts";
import type { SqlObjectNavigationTarget } from "../../apps/desktop/src/lib/sql/sqlNavigation.ts";
const tableTarget: SqlObjectNavigationTarget = { name: "orders", database: "analytics", schema: "reporting", type: "table" };
const viewTarget: SqlObjectNavigationTarget = { ...tableTarget, type: "view" };
const materializedViewTarget: SqlObjectNavigationTarget = { ...tableTarget, type: "materialized_view" };
test("routes table context actions with the resolved target payload", () => {
assert.deepEqual(queryContextObjectRoute("view-data", tableTarget), { event: "viewTableData", payload: [tableTarget] });
assert.deepEqual(queryContextObjectRoute("edit-table-structure", tableTarget), { event: "editTableStructure", payload: [tableTarget] });
assert.deepEqual(queryContextObjectRoute("view-ddl", tableTarget), { event: "viewTableDdl", payload: [tableTarget] });
});
test("routes view source actions with editing intent and type fidelity", () => {
assert.deepEqual(queryContextObjectRoute("edit-view", viewTarget), { event: "openObjectSource", payload: [viewTarget, true] });
assert.deepEqual(queryContextObjectRoute("view-source", viewTarget), { event: "openObjectSource", payload: [viewTarget, false] });
});
test("preserves materialized view type in every routed payload", () => {
assert.deepEqual(queryContextObjectRoute("view-data", materializedViewTarget), { event: "viewTableData", payload: [materializedViewTarget] });
assert.deepEqual(queryContextObjectRoute("edit-view", materializedViewTarget), { event: "openObjectSource", payload: [materializedViewTarget, true] });
assert.deepEqual(queryContextObjectRoute("view-source", materializedViewTarget), { event: "openObjectSource", payload: [materializedViewTarget, false] });
assert.deepEqual(queryContextObjectRoute("view-ddl", materializedViewTarget), { event: "viewTableDdl", payload: [materializedViewTarget] });
});