feat(sidebar): batch actions for multi selected nodes
This commit is contained in:
1 parent
2f71a1a8aa
commit
69a032ed63
37 files changed
+1302
-159
No files matched your search
@@ -12,4 +12,14 @@ describe("add to AI database context", () => {
|
||||
expect(addToAiSource).toContain("else if (hasTreeNodeDatabaseContext(node))");
|
||||
expect(addToAiSource).not.toContain("else if (node.database)");
|
||||
});
|
||||
|
||||
it("adds every selected table as an AI mention", () => {
|
||||
const addToAiStart = appSource.indexOf("async function addToAi");
|
||||
const addToAiEnd = appSource.indexOf("function openAiPanel", addToAiStart);
|
||||
const addToAiSource = appSource.slice(addToAiStart, addToAiEnd);
|
||||
|
||||
expect(addToAiSource).toContain("Array.isArray(nodesInput) ? nodesInput : [nodesInput]");
|
||||
expect(addToAiSource).toContain('entry.type === "table"');
|
||||
expect(addToAiSource).toContain("for (const mention of tableMentions) handle.addTableMention(mention)");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createColumnReferencePayload, createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
|
||||
import { createColumnReferencePayload, createMultiTableReferencePayload, createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
|
||||
|
||||
const commaNewline = { columnNameSeparator: "comma-newline" as const };
|
||||
|
||||
describe("query editor table reference drop", () => {
|
||||
it("inserts a quoted database name for database references", () => {
|
||||
@@ -102,7 +104,7 @@ describe("query editor table reference drop", () => {
|
||||
columnNames: ["id", "order no", "order", "created_at"],
|
||||
databaseType: "mysql",
|
||||
})!;
|
||||
expect(tableReferenceInsertText(mysql)).toBe("id,\n`order no`,\n`order`,\ncreated_at");
|
||||
expect(tableReferenceInsertText(mysql, undefined, commaNewline)).toBe("id,\n`order no`,\n`order`,\ncreated_at");
|
||||
|
||||
const postgres = createColumnReferencePayload({
|
||||
connectionId: "conn-1",
|
||||
@@ -110,7 +112,29 @@ describe("query editor table reference drop", () => {
|
||||
columnNames: ["id", "OrderNo", "select"],
|
||||
databaseType: "postgres",
|
||||
})!;
|
||||
expect(tableReferenceInsertText(postgres)).toBe('id,\n"OrderNo",\n"select"');
|
||||
expect(tableReferenceInsertText(postgres, undefined, commaNewline)).toBe('id,\n"OrderNo",\n"select"');
|
||||
});
|
||||
|
||||
it("uses columnNameSeparator from the payload for multi-column sidebar drag", () => {
|
||||
const comma = createColumnReferencePayload({
|
||||
connectionId: "conn-1",
|
||||
database: "app-db",
|
||||
columnNames: ["id", "name"],
|
||||
databaseType: "mysql",
|
||||
columnNameSeparator: "comma",
|
||||
})!;
|
||||
expect(comma.columnNameSeparator).toBe("comma");
|
||||
expect(parseTableReferencePayload(JSON.stringify(comma))).toEqual(comma);
|
||||
expect(tableReferenceInsertText(comma)).toBe("id,name");
|
||||
|
||||
const newline = createColumnReferencePayload({
|
||||
connectionId: "conn-1",
|
||||
database: "app-db",
|
||||
columnNames: ["id", "name"],
|
||||
databaseType: "mysql",
|
||||
columnNameSeparator: "newline",
|
||||
})!;
|
||||
expect(tableReferenceInsertText(newline)).toBe("id\nname");
|
||||
});
|
||||
|
||||
it("keeps smart quoting for dialects without a dedicated keyword table", () => {
|
||||
@@ -121,7 +145,7 @@ describe("query editor table reference drop", () => {
|
||||
columnNames: ["id", "order no"],
|
||||
databaseType: "oracle",
|
||||
})!;
|
||||
expect(tableReferenceInsertText(oracle)).toBe('id,\n"order no"');
|
||||
expect(tableReferenceInsertText(oracle, undefined, commaNewline)).toBe('id,\n"order no"');
|
||||
});
|
||||
|
||||
it("bare-quotes sqlserver columns when safe and brackets reserved words", () => {
|
||||
@@ -131,7 +155,7 @@ describe("query editor table reference drop", () => {
|
||||
columnNames: ["id", "order", "order no"],
|
||||
databaseType: "sqlserver",
|
||||
})!;
|
||||
expect(tableReferenceInsertText(payload)).toBe("id,\n[order],\n[order no]");
|
||||
expect(tableReferenceInsertText(payload, undefined, commaNewline)).toBe("id,\n[order],\n[order no]");
|
||||
});
|
||||
|
||||
it("falls back to the editor database type when the payload omits one", () => {
|
||||
@@ -140,6 +164,21 @@ describe("query editor table reference drop", () => {
|
||||
database: "app-db",
|
||||
columnNames: ["order no"],
|
||||
})!;
|
||||
expect(tableReferenceInsertText(payload, "mysql")).toBe("`order no`");
|
||||
expect(tableReferenceInsertText(payload, "mysql", commaNewline)).toBe("`order no`");
|
||||
});
|
||||
|
||||
it("inserts multiple table references with the configured separator", () => {
|
||||
const payload = createMultiTableReferencePayload({
|
||||
connectionId: "conn-1",
|
||||
database: "app-db",
|
||||
tableReferences: [
|
||||
{ schema: "public", tableName: "users" },
|
||||
{ schema: "public", tableName: "orders" },
|
||||
],
|
||||
databaseType: "postgres",
|
||||
})!;
|
||||
expect(parseTableReferencePayload(JSON.stringify(payload))).toEqual(payload);
|
||||
expect(tableReferenceInsertText(payload, undefined, { tableNameSeparator: "comma" })).toBe("users,orders");
|
||||
expect(tableReferenceInsertText(payload, undefined, { tableNameSeparator: "comma", includeTableSchema: true })).toBe('"public"."users","public"."orders"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { resolveSidebarDdlTargets } from "@/lib/sidebar/sidebarDdlTargets";
|
||||
|
||||
describe("sidebarDdlTargets", () => {
|
||||
it("resolves multi-select ddl targets in the same execution context", () => {
|
||||
const first: TreeNode = { id: "t1", label: "one", type: "table", connectionId: "c1", database: "db" };
|
||||
const second: TreeNode = { id: "t2", label: "two", type: "view", connectionId: "c1", database: "db" };
|
||||
const otherDb: TreeNode = { id: "t3", label: "three", type: "table", connectionId: "c1", database: "other" };
|
||||
const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [first, second, otherDb] };
|
||||
|
||||
expect(resolveSidebarDdlTargets(first, [group], [second.id, first.id])).toEqual([first, second]);
|
||||
expect(resolveSidebarDdlTargets(first, [group], [otherDb.id, first.id, second.id])).toEqual([first, second]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { allDatabasesExportSourceForNode, databaseExportSourceForNode, sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
|
||||
import { allDatabasesExportSourceForNode, databaseExportSourceForNode, sidebarSameSchemaStructureTargets, sidebarStructureExportTargets, sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
|
||||
|
||||
describe("sidebar export runtime", () => {
|
||||
it("prepares database and table export sources", () => {
|
||||
@@ -27,4 +27,36 @@ describe("sidebar export runtime", () => {
|
||||
expect(sidebarStructureExportTargets(first, [group], [third.id, first.id, second.id])).toEqual([first, second, third]);
|
||||
expect(sidebarStructureExportTargets(first, [group], [second.id])).toEqual([first]);
|
||||
});
|
||||
|
||||
it("limits multi-select diagram and database export prefills to the active schema context", () => {
|
||||
const publicUsers: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
|
||||
const publicOrders: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
|
||||
const salesUsers: TreeNode = { id: "t3", label: "users", type: "table", connectionId: "c1", database: "db", schema: "sales" };
|
||||
const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [publicUsers, publicOrders, salesUsers] };
|
||||
|
||||
expect(sidebarSameSchemaStructureTargets(publicUsers, [group], [publicOrders.id, publicUsers.id, salesUsers.id])).toEqual([publicUsers, publicOrders]);
|
||||
expect(sidebarSameSchemaStructureTargets(publicUsers, [group], [salesUsers.id, publicUsers.id])).toEqual([publicUsers]);
|
||||
});
|
||||
|
||||
it("counts batch data export targets as tables only", () => {
|
||||
const table: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
|
||||
const view: TreeNode = { id: "v1", label: "active_users", type: "view", connectionId: "c1", database: "db", schema: "public" };
|
||||
const other: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
|
||||
const group: TreeNode = { id: "group", label: "Objects", type: "group-tables", children: [table, view, other] };
|
||||
|
||||
expect(sidebarTableDataExportTargets(table, [group], [other.id, table.id, view.id])).toEqual([table, other]);
|
||||
expect(sidebarTableDataExportTargets(view, [group], [other.id, table.id, view.id])).toEqual([view]);
|
||||
expect(sidebarTableDataExportTargets(table, [group], [view.id])).toEqual([table]);
|
||||
});
|
||||
|
||||
it("scopes batch data export targets to the active execution context", () => {
|
||||
const localUsers: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
|
||||
const localOrders: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
|
||||
const otherConnectionOrders: TreeNode = { id: "t3", label: "orders", type: "table", connectionId: "c2", database: "db", schema: "public" };
|
||||
const otherDatabaseUsers: TreeNode = { id: "t4", label: "users", type: "table", connectionId: "c1", database: "other", schema: "public" };
|
||||
const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [localUsers, localOrders, otherConnectionOrders, otherDatabaseUsers] };
|
||||
|
||||
expect(sidebarTableDataExportTargets(localUsers, [group], [localUsers.id, localOrders.id, otherConnectionOrders.id, otherDatabaseUsers.id])).toEqual([localUsers, localOrders]);
|
||||
expect(sidebarTableDataExportTargets(localOrders, [group], [localUsers.id, localOrders.id, otherConnectionOrders.id, otherDatabaseUsers.id])).toEqual([localUsers, localOrders]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { columnNameForSidebarDrag, formatSidebarTableNamesForCopy, normalizeSidebarCopyTableNameSeparator, resolveSidebarColumnDragNames, resolveSidebarColumnDragTargets, resolveSidebarTableCopyTargets } from "@/lib/sidebar/sidebarTableNameCopy";
|
||||
|
||||
describe("sidebarTableNameCopy", () => {
|
||||
const table = (id: string, label: string, schema = "public"): TreeNode => ({
|
||||
id,
|
||||
label,
|
||||
type: "table",
|
||||
connectionId: "c1",
|
||||
database: "db",
|
||||
schema,
|
||||
});
|
||||
|
||||
const column = (id: string, label: string, name: string): TreeNode => ({
|
||||
id,
|
||||
label,
|
||||
type: "column",
|
||||
connectionId: "c1",
|
||||
database: "db",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
meta: { name },
|
||||
});
|
||||
|
||||
it("resolves multi-table copy targets in visible order", () => {
|
||||
const first = table("t1", "one");
|
||||
const second = table("t2", "two");
|
||||
const third = table("t3", "three");
|
||||
expect(resolveSidebarTableCopyTargets(first, [third, first, second])).toEqual([third, first, second]);
|
||||
expect(resolveSidebarTableCopyTargets(first, [second])).toEqual([first]);
|
||||
});
|
||||
|
||||
it("falls back to active node when selection is mixed connection", () => {
|
||||
const first = table("t1", "one");
|
||||
const other = { ...table("t2", "two"), connectionId: "c2" };
|
||||
expect(resolveSidebarTableCopyTargets(first, [first, other])).toEqual([first]);
|
||||
});
|
||||
|
||||
it("formats bare and qualified table names", () => {
|
||||
const targets = [table("t1", "users"), table("t2", "orders", "sales")];
|
||||
expect(formatSidebarTableNamesForCopy(targets, { separator: "comma", includeSchema: false })).toBe("users,orders");
|
||||
expect(formatSidebarTableNamesForCopy(targets, { separator: "newline", includeSchema: true, databaseType: "postgres" })).toBe('"public"."users"\n"sales"."orders"');
|
||||
expect(formatSidebarTableNamesForCopy([table("t1", "users")], { separator: "comma", includeSchema: true, databaseType: "mysql" })).toBe("`users`");
|
||||
expect(formatSidebarTableNamesForCopy([table("t1", "users", "default")], { separator: "comma", includeSchema: true, databaseType: "hive", schema: "default" })).toBe("`default`.`users`");
|
||||
});
|
||||
|
||||
it("resolves multi-column drag targets for the same table", () => {
|
||||
const first = column("c1", "id (bigint)", "id");
|
||||
const second = column("c2", "name (text)", "name");
|
||||
expect(resolveSidebarColumnDragTargets(first, [second, first])).toEqual([second, first]);
|
||||
expect(resolveSidebarColumnDragNames(first, [second, first])).toEqual(["name", "id"]);
|
||||
});
|
||||
|
||||
it("falls back to active column when selection spans tables", () => {
|
||||
const first = column("c1", "id (bigint)", "id");
|
||||
const other = { ...column("c2", "name (text)", "name"), tableName: "orders" };
|
||||
expect(resolveSidebarColumnDragTargets(first, [first, other])).toEqual([first]);
|
||||
});
|
||||
|
||||
it("extracts column drag names from labels", () => {
|
||||
expect(columnNameForSidebarDrag(column("c1", "created_at (timestamp)", "created_at"))).toBe("created_at");
|
||||
});
|
||||
|
||||
it("normalizes separator settings", () => {
|
||||
expect(normalizeSidebarCopyTableNameSeparator("comma-newline")).toBe("comma-newline");
|
||||
expect(normalizeSidebarCopyTableNameSeparator("bogus")).toBe("comma");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { COLUMN_NAME_COPY_SEPARATOR_VALUES, loadColumnNameCopySeparator, type ColumnNameCopySeparator, isColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier.ts";
|
||||
|
||||
@@ -37,18 +38,27 @@ export const DBX_TABLE_REFERENCE_DROP_EVENT = "dbx-table-reference-drop";
|
||||
export const DBX_TABLE_REFERENCE_HOVER_EVENT = "dbx-table-reference-hover";
|
||||
export const DBX_TABLE_REFERENCE_DRAG_END_EVENT = "dbx-table-reference-drag-end";
|
||||
|
||||
export interface QueryEditorTableReferenceEntry {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export interface QueryEditorTableReferencePayload {
|
||||
kind: "dbx-table-reference";
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
tableReferences?: QueryEditorTableReferenceEntry[];
|
||||
columnName?: string;
|
||||
/** 多个结果列一起拖入时按选择顺序排列;单列沿用 columnName。 */
|
||||
columnNames?: string[];
|
||||
referenceType?: "database" | "table" | "column";
|
||||
databaseType?: DatabaseType;
|
||||
driverProfile?: string;
|
||||
columnNameSeparator?: ColumnNameCopySeparator;
|
||||
tableNameSeparator?: ColumnNameCopySeparator;
|
||||
includeTableSchema?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryEditorTableReferenceDropDetail {
|
||||
@@ -62,16 +72,38 @@ export interface QueryEditorTableReferenceHoverDetail {
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface TableReferenceInsertOptions {
|
||||
tableNameSeparator?: ColumnNameCopySeparator;
|
||||
includeTableSchema?: boolean;
|
||||
columnNameSeparator?: ColumnNameCopySeparator;
|
||||
}
|
||||
|
||||
function normalizeColumnNames(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((name) => (typeof name === "string" ? name.trim() : "")).filter((name) => name.length > 0);
|
||||
}
|
||||
|
||||
function normalizeTableReferences(value: unknown): QueryEditorTableReferenceEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const entries: QueryEditorTableReferenceEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const tableName = typeof (item as QueryEditorTableReferenceEntry).tableName === "string" ? (item as QueryEditorTableReferenceEntry).tableName.trim() : "";
|
||||
if (!tableName) continue;
|
||||
const schema = typeof (item as QueryEditorTableReferenceEntry).schema === "string" ? (item as QueryEditorTableReferenceEntry).schema : undefined;
|
||||
entries.push(schema ? { schema, tableName } : { tableName });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** schema/databaseType/driverProfile 为可选元数据,payload 各构造路径共用同一拷贝规则。 */
|
||||
function applyOptionalReferenceMeta(payload: QueryEditorTableReferencePayload, source: Partial<QueryEditorTableReferencePayload>) {
|
||||
if (typeof source.schema === "string" && source.schema) payload.schema = source.schema;
|
||||
if (source.databaseType) payload.databaseType = source.databaseType;
|
||||
if (typeof source.driverProfile === "string" && source.driverProfile) payload.driverProfile = source.driverProfile;
|
||||
if (isColumnNameCopySeparator(source.columnNameSeparator)) payload.columnNameSeparator = source.columnNameSeparator;
|
||||
if (isColumnNameCopySeparator(source.tableNameSeparator)) payload.tableNameSeparator = source.tableNameSeparator;
|
||||
if (source.includeTableSchema === true) payload.includeTableSchema = true;
|
||||
}
|
||||
|
||||
let activeTableReferencePayload: QueryEditorTableReferencePayload | null = null;
|
||||
@@ -107,7 +139,40 @@ export function createTableReferencePayload(options: {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createColumnReferencePayload(options: { connectionId?: string; database?: string; schema?: string; columnNames?: readonly (string | undefined | null)[]; databaseType?: DatabaseType }): QueryEditorTableReferencePayload | null {
|
||||
export function createMultiTableReferencePayload(options: {
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
tableReferences?: readonly QueryEditorTableReferenceEntry[];
|
||||
databaseType?: DatabaseType;
|
||||
driverProfile?: string;
|
||||
tableNameSeparator?: ColumnNameCopySeparator;
|
||||
includeTableSchema?: boolean;
|
||||
}): QueryEditorTableReferencePayload | null {
|
||||
if (!options.connectionId || options.database == null) return null;
|
||||
const tableReferences = normalizeTableReferences(options.tableReferences);
|
||||
if (tableReferences.length === 0) return null;
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
kind: "dbx-table-reference",
|
||||
connectionId: options.connectionId,
|
||||
database: options.database,
|
||||
referenceType: "table",
|
||||
};
|
||||
if (tableReferences.length === 1) {
|
||||
payload.tableName = tableReferences[0]!.tableName;
|
||||
if (tableReferences[0]!.schema) payload.schema = tableReferences[0]!.schema;
|
||||
} else {
|
||||
payload.tableReferences = tableReferences;
|
||||
}
|
||||
applyOptionalReferenceMeta(payload, {
|
||||
databaseType: options.databaseType,
|
||||
driverProfile: options.driverProfile,
|
||||
tableNameSeparator: options.tableNameSeparator,
|
||||
includeTableSchema: options.includeTableSchema,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createColumnReferencePayload(options: { connectionId?: string; database?: string; schema?: string; columnNames?: readonly (string | undefined | null)[]; databaseType?: DatabaseType; columnNameSeparator?: ColumnNameCopySeparator }): QueryEditorTableReferencePayload | null {
|
||||
const columnNames = normalizeColumnNames(options.columnNames);
|
||||
if (!options.connectionId || options.database == null || columnNames.length === 0) return null;
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
@@ -143,10 +208,8 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
|
||||
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
|
||||
return payload;
|
||||
}
|
||||
if (typeof parsed.tableName !== "string" || !parsed.tableName) {
|
||||
// 无来源表的纯列引用(如从查询结果列头拖入),tableName 非必需。
|
||||
const columnNames = normalizeColumnNames(parsed.columnNames);
|
||||
if (columnNames.length === 0) return null;
|
||||
const columnNames = normalizeColumnNames(parsed.columnNames);
|
||||
if (columnNames.length > 0 && (parsed.referenceType === "column" || !parsed.tableName)) {
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
kind: "dbx-table-reference",
|
||||
connectionId: parsed.connectionId,
|
||||
@@ -157,18 +220,34 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
|
||||
applyOptionalReferenceMeta(payload, parsed);
|
||||
return payload;
|
||||
}
|
||||
const tableReferences = normalizeTableReferences(parsed.tableReferences);
|
||||
if (tableReferences.length > 1) {
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
kind: "dbx-table-reference",
|
||||
connectionId: parsed.connectionId,
|
||||
database: parsed.database,
|
||||
referenceType: "table",
|
||||
tableReferences,
|
||||
};
|
||||
applyOptionalReferenceMeta(payload, parsed);
|
||||
return payload;
|
||||
}
|
||||
if (typeof parsed.tableName !== "string" || !parsed.tableName) {
|
||||
return null;
|
||||
}
|
||||
const columnName = typeof parsed.columnName === "string" && parsed.columnName ? parsed.columnName : undefined;
|
||||
const referenceType = parsed.referenceType === "column" || columnName ? "column" : "table";
|
||||
if (referenceType === "column" && !columnName) return null;
|
||||
const referenceType = parsed.referenceType === "column" || columnName || columnNames.length > 0 ? "column" : "table";
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
kind: "dbx-table-reference",
|
||||
connectionId: parsed.connectionId,
|
||||
database: parsed.database,
|
||||
tableName: parsed.tableName,
|
||||
};
|
||||
if (referenceType === "column" && columnName) {
|
||||
payload.columnName = columnName;
|
||||
if (referenceType === "column") {
|
||||
payload.referenceType = "column";
|
||||
if (columnNames.length > 0) payload.columnNames = columnNames;
|
||||
else if (columnName) payload.columnName = columnName;
|
||||
else return null;
|
||||
}
|
||||
applyOptionalReferenceMeta(payload, parsed);
|
||||
return payload;
|
||||
@@ -211,14 +290,40 @@ export function createTableReferenceDragEndEvent(): Event {
|
||||
return new Event(DBX_TABLE_REFERENCE_DRAG_END_EVENT);
|
||||
}
|
||||
|
||||
export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType): string {
|
||||
function separatorValue(payload: QueryEditorTableReferencePayload, options: TableReferenceInsertOptions | undefined, kind: "table" | "column"): string {
|
||||
const fromPayload = kind === "table" ? payload.tableNameSeparator : payload.columnNameSeparator;
|
||||
const fromOptions = kind === "table" ? options?.tableNameSeparator : options?.columnNameSeparator;
|
||||
const fallback = kind === "column" ? loadColumnNameCopySeparator() : "comma";
|
||||
const separator = fromOptions ?? fromPayload ?? fallback;
|
||||
return COLUMN_NAME_COPY_SEPARATOR_VALUES[separator] ?? (kind === "column" ? "\t" : ",");
|
||||
}
|
||||
|
||||
export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType, options?: TableReferenceInsertOptions): string {
|
||||
const databaseType = payload.databaseType ?? fallbackDatabaseType;
|
||||
if (payload.referenceType === "database") {
|
||||
return quoteTableIdentifier(databaseType, payload.database);
|
||||
}
|
||||
const columnNames = payload.columnNames?.length ? payload.columnNames : payload.columnName ? [payload.columnName] : [];
|
||||
if (payload.referenceType === "column" && columnNames.length > 0) {
|
||||
return columnNames.map((name) => quoteColumnReferenceName(databaseType, name)).join(",\n");
|
||||
return columnNames.map((name) => quoteColumnReferenceName(databaseType, name)).join(separatorValue(payload, options, "column"));
|
||||
}
|
||||
const includeSchema = payload.includeTableSchema ?? options?.includeTableSchema ?? false;
|
||||
const tableSeparator = separatorValue(payload, options, "table");
|
||||
const tableReferences = payload.tableReferences?.length ? payload.tableReferences : payload.tableName ? [{ schema: payload.schema, tableName: payload.tableName }] : [];
|
||||
if (tableReferences.length > 1 || (tableReferences.length === 1 && includeSchema)) {
|
||||
return tableReferences
|
||||
.map((entry) =>
|
||||
includeSchema
|
||||
? qualifiedTableName({
|
||||
databaseType,
|
||||
driverProfile: payload.driverProfile,
|
||||
schema: entry.schema,
|
||||
tableName: entry.tableName,
|
||||
database: payload.database,
|
||||
})
|
||||
: entry.tableName,
|
||||
)
|
||||
.join(tableSeparator);
|
||||
}
|
||||
const tableName = payload.tableName || payload.database;
|
||||
return qualifiedTableName({
|
||||
|
||||
@@ -80,6 +80,8 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"sidebarIndent",
|
||||
"sidebarFontSize",
|
||||
"sidebarHiddenTablePrefixes",
|
||||
"sidebarCopyTableNameSeparator",
|
||||
"sidebarCopyTableNameIncludeSchema",
|
||||
"redisKeyTemplates",
|
||||
"exportBatchSize",
|
||||
"exportRowLimitEnabled",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
|
||||
import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
|
||||
|
||||
export type SidebarDdlTarget = TreeNode & { connectionId: string; database: string };
|
||||
|
||||
export function resolveSidebarDdlTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarDdlTarget[] {
|
||||
const targets = sidebarStructureExportTargets(activeNode, treeNodes, selectedNodeIds);
|
||||
if (!targets.length || !activeNode.connectionId || !activeNode.database) return [];
|
||||
return sidebarDdlTargetsForExecutionContext(activeNode as SidebarDdlTarget, targets);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
|
||||
|
||||
export interface SidebarDatabaseExportSource {
|
||||
connectionId: string;
|
||||
@@ -25,15 +26,26 @@ export function allDatabasesExportSourceForNode(node: TreeNode): SidebarDatabase
|
||||
return { connectionId: node.connectionId, database: "", allDatabases: true };
|
||||
}
|
||||
|
||||
export function sidebarStructureExportTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): Array<TreeNode & { connectionId: string; database: string }> {
|
||||
const canExport = (node: TreeNode): node is TreeNode & { connectionId: string; database: string } => (node.type === "table" || node.type === "view" || node.type === "materialized_view") && !!node.connectionId && !!node.database;
|
||||
if (!canExport(activeNode)) return [];
|
||||
export type SidebarStructureExportTarget = TreeNode & { connectionId: string; database: string };
|
||||
|
||||
function canStructureExport(node: TreeNode): node is SidebarStructureExportTarget {
|
||||
return (node.type === "table" || node.type === "view" || node.type === "materialized_view") && !!node.connectionId && !!node.database;
|
||||
}
|
||||
|
||||
/** connection + database + catalog + schema identity for diagram and database-export prefills. */
|
||||
export function sidebarStructureTargetSchemaContext(node: TreeNode): string {
|
||||
if (!node.connectionId || !node.database) return "";
|
||||
return `${node.connectionId}\0${node.database}\0${node.catalog ?? ""}\0${node.schema ?? ""}`;
|
||||
}
|
||||
|
||||
export function sidebarStructureExportTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarStructureExportTarget[] {
|
||||
if (!canStructureExport(activeNode)) return [];
|
||||
|
||||
const selected = new Set(selectedNodeIds);
|
||||
const targets: Array<TreeNode & { connectionId: string; database: string }> = [];
|
||||
const targets: SidebarStructureExportTarget[] = [];
|
||||
const visit = (nodes: readonly TreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (selected.has(node.id) && canExport(node)) targets.push(node);
|
||||
if (selected.has(node.id) && canStructureExport(node)) targets.push(node);
|
||||
if (node.children) visit(node.children);
|
||||
if (node.hiddenChildren) visit(node.hiddenChildren);
|
||||
}
|
||||
@@ -41,3 +53,23 @@ export function sidebarStructureExportTargets(activeNode: TreeNode, treeNodes: r
|
||||
visit(treeNodes);
|
||||
return targets.length > 1 && targets.some((node) => node.id === activeNode.id) ? targets : [activeNode];
|
||||
}
|
||||
|
||||
/** Keeps multi-select only when every target shares the active node's schema context. */
|
||||
export function sidebarSameSchemaStructureTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarStructureExportTarget[] {
|
||||
if (!canStructureExport(activeNode)) return [];
|
||||
const targets = sidebarStructureExportTargets(activeNode, treeNodes, selectedNodeIds);
|
||||
if (targets.length <= 1) return targets;
|
||||
const activeContext = sidebarStructureTargetSchemaContext(activeNode);
|
||||
const sameContext = targets.filter((target) => sidebarStructureTargetSchemaContext(target) === activeContext);
|
||||
return sameContext.length > 1 && sameContext.some((target) => target.id === activeNode.id) ? sameContext : [activeNode];
|
||||
}
|
||||
|
||||
/** Batch data export includes tables only and stays inside the active execution context; single-target fallback still allows one view/materialized view. */
|
||||
export function sidebarTableDataExportTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarStructureExportTarget[] {
|
||||
const targets = canStructureExport(activeNode) ? sidebarDdlTargetsForExecutionContext(activeNode, sidebarStructureExportTargets(activeNode, treeNodes, selectedNodeIds)) : [];
|
||||
const tables = targets.filter((node) => node.type === "table");
|
||||
if (tables.length > 1 && tables.some((node) => node.id === activeNode.id)) return tables;
|
||||
if (activeNode.type === "table" && canStructureExport(activeNode)) return [activeNode];
|
||||
if (canStructureExport(activeNode) && targets.some((node) => node.id === activeNode.id)) return [activeNode];
|
||||
return tables;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ColumnInfo, DatabaseType, TreeNode } from "@/types/database";
|
||||
import { COLUMN_NAME_COPY_SEPARATOR_VALUES, type ColumnNameCopySeparator, isColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import { copyNameForTreeNode } from "@/lib/sidebar/treeNodeClick";
|
||||
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
|
||||
|
||||
export type SidebarTableCopyTarget = TreeNode & { connectionId: string; database: string };
|
||||
|
||||
const TABLE_COPY_NODE_TYPES = new Set<TreeNode["type"]>(["table", "view", "materialized_view"]);
|
||||
|
||||
function isTableCopyTarget(node: TreeNode): node is SidebarTableCopyTarget {
|
||||
return TABLE_COPY_NODE_TYPES.has(node.type) && !!node.connectionId && !!node.database;
|
||||
}
|
||||
|
||||
export function columnNameForSidebarDrag(node: TreeNode): string {
|
||||
if (node.type !== "column") return copyNameForTreeNode(node);
|
||||
const column = node.meta as Partial<ColumnInfo> | undefined;
|
||||
if (typeof column?.name === "string" && column.name) return column.name;
|
||||
return node.label.replace(/\s+\([^()]*\)$/, "");
|
||||
}
|
||||
|
||||
function sameColumnTableContext(first: TreeNode, node: TreeNode): boolean {
|
||||
return node.type === "column" && !!node.connectionId && node.database != null && node.connectionId === first.connectionId && node.database === first.database && (node.schema ?? "") === (first.schema ?? "") && !!node.tableName && node.tableName === first.tableName;
|
||||
}
|
||||
|
||||
export function resolveSidebarTableCopyTargets(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): SidebarTableCopyTarget[] {
|
||||
if (!isTableCopyTarget(activeNode)) return [];
|
||||
if (selectedNodes.length <= 1 || !selectedNodes.some((node) => node.id === activeNode.id)) return [activeNode];
|
||||
const targets = selectedNodes.filter(isTableCopyTarget);
|
||||
if (targets.length <= 1) return [activeNode];
|
||||
const first = targets[0]!;
|
||||
if (!targets.every((node) => node.connectionId === first.connectionId && node.database === first.database && (node.catalog ?? "") === (first.catalog ?? ""))) {
|
||||
return [activeNode];
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function resolveSidebarColumnDragTargets(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): TreeNode[] {
|
||||
if (activeNode.type !== "column" || !activeNode.tableName) return [activeNode];
|
||||
if (selectedNodes.length <= 1 || !selectedNodes.some((node) => node.id === activeNode.id)) return [activeNode];
|
||||
const targets = selectedNodes.filter((node) => node.type === "column" && sameColumnTableContext(activeNode, node));
|
||||
return targets.length > 1 ? targets : [activeNode];
|
||||
}
|
||||
|
||||
export function resolveSidebarColumnDragNames(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): string[] {
|
||||
return resolveSidebarColumnDragTargets(activeNode, selectedNodes)
|
||||
.map(columnNameForSidebarDrag)
|
||||
.filter((name) => name.length > 0);
|
||||
}
|
||||
|
||||
export interface FormatSidebarTableNamesOptions {
|
||||
separator: ColumnNameCopySeparator;
|
||||
includeSchema: boolean;
|
||||
databaseType?: DatabaseType;
|
||||
driverProfile?: string;
|
||||
identifierQuote?: string;
|
||||
}
|
||||
|
||||
export function formatSidebarTableNamesForCopy(targets: readonly SidebarTableCopyTarget[], options: FormatSidebarTableNamesOptions): string {
|
||||
const separator = COLUMN_NAME_COPY_SEPARATOR_VALUES[options.separator] ?? ",";
|
||||
const parts = targets.map((target) => {
|
||||
if (!options.includeSchema) return copyNameForTreeNode(target);
|
||||
return qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
driverProfile: options.driverProfile,
|
||||
identifierQuote: options.identifierQuote,
|
||||
schema: target.schema,
|
||||
tableName: target.label,
|
||||
database: target.database,
|
||||
catalog: target.catalog,
|
||||
});
|
||||
});
|
||||
return parts.join(separator);
|
||||
}
|
||||
|
||||
export function normalizeSidebarCopyTableNameSeparator(value: unknown): ColumnNameCopySeparator {
|
||||
return isColumnNameCopySeparator(value) ? value : "comma";
|
||||
}
|
||||
|
||||
export function sidebarTableCopyNodes(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): TreeNode[] {
|
||||
return selectedNodes.length > 1 && selectedNodes.some((node) => node.id === activeNode.id) ? [...selectedNodes] : [activeNode];
|
||||
}
|
||||
|
||||
export function formatSidebarTableCopyText(activeNode: TreeNode, selectedNodes: readonly TreeNode[], options: FormatSidebarTableNamesOptions): string {
|
||||
const tableTargets = resolveSidebarTableCopyTargets(activeNode, selectedNodes);
|
||||
const separator = COLUMN_NAME_COPY_SEPARATOR_VALUES[options.separator] ?? ",";
|
||||
if (tableTargets.length > 0 && (tableTargets.length > 1 || options.includeSchema)) {
|
||||
return formatSidebarTableNamesForCopy(tableTargets, options);
|
||||
}
|
||||
return sidebarTableCopyNodes(activeNode, selectedNodes).map(copyNameForTreeNode).join(separator);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export interface SidebarTreeRuntimeHost {
|
||||
handleRowKeydown(node: TreeNode, event: KeyboardEvent): void;
|
||||
openPrimaryVisibleFilter(node: TreeNode): void;
|
||||
openDataInNewTab(node: TreeNode): void;
|
||||
openDdlForSelection(node: TreeNode, selectedNodeIds: readonly string[]): Promise<boolean>;
|
||||
requestPaste(node: TreeNode): boolean;
|
||||
toggleNode(node: TreeNode): void;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user