test(editor): cover context menu routing payloads
This commit is contained in:
parent
d3118256a4
commit
711d0ec57b
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
});
|
||||
Loading…
Reference in New Issue