feat(editor): drag result grid columns into the SQL editor

This commit is contained in:
Yang Fei authored and GitHub committed 2026-08-27 17:16:57 +08:00
1 parent 9e4fe8ce56
commit ab4e747bf9
17 files changed
+911 -25

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
import { createColumnReferencePayload, createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
describe("query editor table reference drop", () => {
it("inserts a quoted database name for database references", () => {
@@ -38,4 +38,108 @@ describe("query editor table reference drop", () => {
expect(parseTableReferencePayload(JSON.stringify(payload))).toEqual(payload);
});
it("creates a column-only payload without a source table", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no"],
databaseType: "mysql",
});
expect(payload).toEqual({
kind: "dbx-table-reference",
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no"],
referenceType: "column",
databaseType: "mysql",
} satisfies Partial<typeof payload>);
expect(payload && "tableName" in payload).toBe(false);
});
it("rejects column payloads without connection, database, or names", () => {
expect(createColumnReferencePayload({ database: "db", columnNames: ["id"] })).toBeNull();
expect(createColumnReferencePayload({ connectionId: "conn-1", columnNames: ["id"] })).toBeNull();
expect(createColumnReferencePayload({ connectionId: "conn-1", database: "db", columnNames: [] })).toBeNull();
expect(createColumnReferencePayload({ connectionId: "conn-1", database: "db", columnNames: ["", " "] })).toBeNull();
});
it("trims surrounding whitespace from column names instead of inserting it verbatim", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "db",
columnNames: [" id ", "name"],
})!;
expect(payload.columnNames).toEqual(["id", "name"]);
});
it("round-trips multi-column payloads and keeps legacy single-column payloads parseable", () => {
const multi = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
schema: "public",
columnNames: ["id", "name"],
databaseType: "postgres",
})!;
expect(parseTableReferencePayload(JSON.stringify(multi))).toEqual(multi);
const legacy = createTableReferencePayload({
connectionId: "conn-1",
database: "app-db",
tableName: "users",
columnName: "email",
databaseType: "postgres",
})!;
expect(parseTableReferencePayload(JSON.stringify(legacy))).toEqual(legacy);
});
it("inserts smart-quoted names per selected column joined by comma + newline", () => {
// 普通名称裸输出,保留字/含特殊字符才加方言引号
const mysql = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no", "order", "created_at"],
databaseType: "mysql",
})!;
expect(tableReferenceInsertText(mysql)).toBe("id,\n`order no`,\n`order`,\ncreated_at");
const postgres = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "OrderNo", "select"],
databaseType: "postgres",
})!;
expect(tableReferenceInsertText(postgres)).toBe('id,\n"OrderNo",\n"select"');
});
it("keeps smart quoting for dialects without a dedicated keyword table", () => {
// 未知方言用通用保守判定:普通名裸输出,特殊字符走全量引号回退
const oracle = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no"],
databaseType: "oracle",
})!;
expect(tableReferenceInsertText(oracle)).toBe('id,\n"order no"');
});
it("bare-quotes sqlserver columns when safe and brackets reserved words", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order", "order no"],
databaseType: "sqlserver",
})!;
expect(tableReferenceInsertText(payload)).toBe("id,\n[order],\n[order no]");
});
it("falls back to the editor database type when the payload omits one", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["order no"],
})!;
expect(tableReferenceInsertText(payload, "mysql")).toBe("`order no`");
});
});
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
afterEach(() => {
document.body.innerHTML = "";
document.body.className = "";
document.body.style.cursor = "";
});
describe("isOverSqlEditorTarget", () => {
it("detects pointers inside the query editor root only", () => {
const editor = document.createElement("div");
editor.setAttribute("data-query-editor-root", "");
const inner = document.createElement("textarea");
editor.appendChild(inner);
document.body.appendChild(editor);
document.elementFromPoint = () => inner;
expect(isOverSqlEditorTarget(10, 10)).toBe(true);
document.elementFromPoint = () => document.body;
expect(isOverSqlEditorTarget(10, 10)).toBe(false);
});
it("falls back to geometric bounds when elementFromPoint hits an overlay", () => {
const editor = document.createElement("div");
editor.setAttribute("data-query-editor-root", "");
Object.defineProperty(editor, "getBoundingClientRect", {
value: () => ({ left: 0, top: 0, right: 400, bottom: 300 }),
});
document.body.appendChild(editor);
// 命中被透明覆盖层拦截时,回退为编辑器包围盒判定。
document.elementFromPoint = () => null;
expect(isOverSqlEditorTarget(10, 10)).toBe(true);
// 覆盖层命中但不落在任何编辑器包围盒内仍为 false。
const farEditor = document.createElement("div");
farEditor.setAttribute("data-query-editor-root", "");
Object.defineProperty(farEditor, "getBoundingClientRect", {
value: () => ({ left: 500, top: 500, right: 900, bottom: 800 }),
});
document.body.appendChild(farEditor);
document.elementFromPoint = () => null;
expect(isOverSqlEditorTarget(450, 450)).toBe(false);
expect(isOverSqlEditorTarget(600, 600)).toBe(true);
});
});
describe("isPointOverElementRoot", () => {
it("requires elementFromPoint to hit inside the given root", () => {
const editor = document.createElement("div");
const inner = document.createElement("textarea");
editor.appendChild(inner);
document.body.appendChild(editor);
document.elementFromPoint = () => inner;
expect(isPointOverElementRoot(10, 10, editor)).toBe(true);
document.elementFromPoint = () => document.body;
expect(isPointOverElementRoot(10, 10, editor)).toBe(false);
expect(isPointOverElementRoot(10, 10, null)).toBe(false);
});
it("falls back to the root bounding box when elementFromPoint hits an overlay", () => {
const editor = document.createElement("div");
Object.defineProperty(editor, "getBoundingClientRect", {
value: () => ({ left: 0, top: 0, right: 400, bottom: 300 }),
});
document.body.appendChild(editor);
// 覆盖层拦截时回退为传入根节点的包围盒判定。
const overlay = document.createElement("div");
document.elementFromPoint = () => overlay;
expect(isPointOverElementRoot(10, 10, editor)).toBe(true);
expect(isPointOverElementRoot(450, 450, editor)).toBe(false);
});
});
describe("beginTableReferenceDragFeedback", () => {
it("shows a following chip and restores body state on end", () => {
const feedback = beginTableReferenceDragFeedback("id, name 等 3 列");
const chip = document.querySelector<HTMLElement>("[data-table-reference-drag-chip]");
expect(chip?.textContent).toBe("id, name 等 3 列");
expect(document.body.style.cursor).toBe("copy");
expect(chip?.style.visibility).toBe("hidden");
feedback.update(40, 60);
expect(chip?.style.visibility).toBe("visible");
expect(Number.parseFloat(chip!.style.left)).toBeGreaterThanOrEqual(8);
expect(Number.parseFloat(chip!.style.top)).toBeGreaterThanOrEqual(8);
feedback.end();
expect(document.querySelector("[data-table-reference-drag-chip]")).toBeNull();
expect(document.body.style.cursor).toBe("");
});
it("clamps the chip inside the viewport", () => {
const feedback = beginTableReferenceDragFeedback("col");
const chip = document.querySelector<HTMLElement>("[data-table-reference-drag-chip]")!;
feedback.update(window.innerWidth - 2, window.innerHeight - 2);
const left = Number.parseFloat(chip.style.left);
const top = Number.parseFloat(chip.style.top);
expect(left + chip.getBoundingClientRect().width).toBeLessThanOrEqual(window.innerWidth);
expect(top + chip.getBoundingClientRect().height).toBeLessThanOrEqual(window.innerHeight);
feedback.end();
});
});
@@ -1,8 +1,41 @@
import type { DatabaseType } from "@/types/database";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier.ts";
/** 智能引号使用反引号的方言族。 */
const SMART_QUOTE_BACKTICK_TYPES = new Set<DatabaseType>(["mysql", "clickhouse", "hive", "kyuubi", "impala", "spark", "databricks", "databend", "tdengine", "access", "doris", "starrocks", "goldendb"]);
/** 智能引号使用双引号的方言族(SQL Server 方括号除外)。 */
const SMART_QUOTE_DOUBLE_TYPES = new Set<DatabaseType>(["postgres", "gaussdb", "opengauss"]);
/** SQL Server 族智能引号使用方括号。 */
const SMART_QUOTE_BRACKET_TYPES = new Set<DatabaseType>(["sqlserver"]);
/**
* 列引用插入的按需引号:普通名称(合法标识符字符、非保留字)裸输出,
* 保留字或含特殊字符时按方言加引号。表名/库名仍走全量引号。
*
* MySQL/PG/SQL Server 族用各自的保留字判定;其余方言无专属保留字表,
* 用「严格标识符正则 + PG/MySQL 保留字并集」做通用保守判定。
*/
function quoteColumnReferenceName(databaseType: DatabaseType | undefined, name: string): string {
if (databaseType && SMART_QUOTE_BACKTICK_TYPES.has(databaseType)) {
return requiresMysqlIdentifierQuote(name) ? `\`${name.replace(/`/g, "``")}\`` : name;
}
if (databaseType && SMART_QUOTE_DOUBLE_TYPES.has(databaseType)) {
// PG 族裸标识符会折叠为小写,混合大小写必须加引号保留原样。
return requiresPostgresIdentifierQuote(name) ? `"${name.replace(/"/g, '""')}"` : name;
}
if (databaseType && SMART_QUOTE_BRACKET_TYPES.has(databaseType)) {
return requiresPostgresIdentifierQuote(name) ? `[${name.replace(/\]/g, "]]")}]` : name;
}
// 其余方言:引号格式仍由 quoteTableIdentifier 按方言决定,是否加引号
// 用通用保守判定(严格标识符正则 + PG/MySQL 保留字并集)。
return requiresMysqlIdentifierQuote(name) ? quoteTableIdentifier(databaseType, name) : name;
}
export const DBX_TABLE_REFERENCE_MIME = "application/x-dbx-table-reference";
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 QueryEditorTableReferencePayload {
kind: "dbx-table-reference";
@@ -11,6 +44,8 @@ export interface QueryEditorTableReferencePayload {
schema?: string;
tableName?: string;
columnName?: string;
/** 多个结果列一起拖入时按选择顺序排列;单列沿用 columnName。 */
columnNames?: string[];
referenceType?: "database" | "table" | "column";
databaseType?: DatabaseType;
driverProfile?: string;
@@ -22,6 +57,23 @@ export interface QueryEditorTableReferenceDropDetail {
clientY: number;
}
export interface QueryEditorTableReferenceHoverDetail {
clientX: number;
clientY: number;
}
function normalizeColumnNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((name) => (typeof name === "string" ? name.trim() : "")).filter((name) => name.length > 0);
}
/** 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;
}
let activeTableReferencePayload: QueryEditorTableReferencePayload | null = null;
export function createTableReferencePayload(options: {
@@ -51,9 +103,21 @@ export function createTableReferencePayload(options: {
payload.columnName = options.columnName;
payload.referenceType = "column";
}
if (options.schema) payload.schema = options.schema;
if (options.databaseType) payload.databaseType = options.databaseType;
if (options.driverProfile) payload.driverProfile = options.driverProfile;
applyOptionalReferenceMeta(payload, options);
return payload;
}
export function createColumnReferencePayload(options: { connectionId?: string; database?: string; schema?: string; columnNames?: readonly (string | undefined | null)[]; databaseType?: DatabaseType }): QueryEditorTableReferencePayload | null {
const columnNames = normalizeColumnNames(options.columnNames);
if (!options.connectionId || options.database == null || columnNames.length === 0) return null;
const payload: QueryEditorTableReferencePayload = {
kind: "dbx-table-reference",
connectionId: options.connectionId,
database: options.database,
referenceType: "column",
columnNames,
};
applyOptionalReferenceMeta(payload, options as Partial<QueryEditorTableReferencePayload>);
return payload;
}
@@ -79,7 +143,20 @@ 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) return null;
if (typeof parsed.tableName !== "string" || !parsed.tableName) {
// 无来源表的纯列引用(如从查询结果列头拖入),tableName 非必需。
const columnNames = normalizeColumnNames(parsed.columnNames);
if (columnNames.length === 0) return null;
const payload: QueryEditorTableReferencePayload = {
kind: "dbx-table-reference",
connectionId: parsed.connectionId,
database: parsed.database,
referenceType: "column",
columnNames,
};
applyOptionalReferenceMeta(payload, parsed);
return payload;
}
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;
@@ -93,9 +170,7 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
payload.columnName = columnName;
payload.referenceType = "column";
}
if (typeof parsed.schema === "string" && parsed.schema) payload.schema = parsed.schema;
if (parsed.databaseType) payload.databaseType = parsed.databaseType;
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
applyOptionalReferenceMeta(payload, parsed);
return payload;
} catch {
return null;
@@ -128,13 +203,22 @@ export function createTableReferenceDropEvent(detail: QueryEditorTableReferenceD
return new CustomEvent<QueryEditorTableReferenceDropDetail>(DBX_TABLE_REFERENCE_DROP_EVENT, { detail });
}
export function createTableReferenceHoverEvent(detail: QueryEditorTableReferenceHoverDetail) {
return new CustomEvent<QueryEditorTableReferenceHoverDetail>(DBX_TABLE_REFERENCE_HOVER_EVENT, { detail });
}
export function createTableReferenceDragEndEvent(): Event {
return new Event(DBX_TABLE_REFERENCE_DRAG_END_EVENT);
}
export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType): string {
const databaseType = payload.databaseType ?? fallbackDatabaseType;
if (payload.referenceType === "database") {
return quoteTableIdentifier(databaseType, payload.database);
}
if (payload.referenceType === "column" && payload.columnName) {
return quoteTableIdentifier(databaseType, payload.columnName);
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");
}
const tableName = payload.tableName || payload.database;
return qualifiedTableName({
@@ -0,0 +1,70 @@
/**
* 表引用拖拽(侧边栏表/列、查询结果列头)共用的指针模拟拖拽反馈:
* 跟随鼠标的 chip 浮层 + body copy 光标。落点探测与插入见 queryEditorTableDrop.ts。
*/
export const QUERY_EDITOR_DROP_TARGET_SELECTOR = "[data-query-editor-root]";
/** 拖拽期间加在 body 上,禁用文本选择(样式见 globals.css)。 */
export const TABLE_REFERENCE_DRAGGING_CLASS = "dbx-table-reference-dragging";
const CHIP_OFFSET_X = 14;
const CHIP_OFFSET_Y = 18;
const CHIP_VIEWPORT_MARGIN = 8;
/**
* elementFromPoint 命中 root 内部才算命中;elementFromPoint 可能被透明覆盖层
* 拦截(面板层等),此时回退为 root 包围盒的几何包含判定。
*/
export function isPointOverElementRoot(clientX: number, clientY: number, root: Element | null | undefined, doc: Document = document): boolean {
if (!root) return false;
const target = doc.elementFromPoint(clientX, clientY);
if (target instanceof Element && root.contains(target)) return true;
const rect = root.getBoundingClientRect();
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}
export function isOverSqlEditorTarget(clientX: number, clientY: number, doc: Document = document): boolean {
for (const root of doc.querySelectorAll(QUERY_EDITOR_DROP_TARGET_SELECTOR)) {
if (isPointOverElementRoot(clientX, clientY, root, doc)) return true;
}
return false;
}
/** 多列摘要由 i18n 插值完成:调用方以 { names: 前两个列名, count: 总数 } 调 t("grid.columnDragChipMany", ...)。 */
export interface TableReferenceDragFeedback {
update(clientX: number, clientY: number): void;
end(): void;
}
export function beginTableReferenceDragFeedback(label: string, doc: Document = document): TableReferenceDragFeedback {
const chip = doc.createElement("div");
chip.dataset.tableReferenceDragChip = "";
chip.setAttribute("aria-hidden", "true");
chip.className = "pointer-events-none fixed z-[9999] max-w-72 truncate rounded-md border border-border bg-popover px-2 py-1 font-mono text-xs leading-4 text-popover-foreground shadow-lg";
chip.textContent = label;
chip.style.visibility = "hidden";
doc.body.appendChild(chip);
doc.body.classList.add(TABLE_REFERENCE_DRAGGING_CLASS);
doc.body.style.cursor = "copy";
const viewport = doc.defaultView ?? window;
const update = (clientX: number, clientY: number) => {
if (chip.style.visibility === "hidden") chip.style.visibility = "visible";
const rect = chip.getBoundingClientRect();
const x = Math.min(clientX + CHIP_OFFSET_X, viewport.innerWidth - rect.width - CHIP_VIEWPORT_MARGIN);
const y = Math.min(clientY + CHIP_OFFSET_Y, viewport.innerHeight - rect.height - CHIP_VIEWPORT_MARGIN);
chip.style.left = `${Math.max(CHIP_VIEWPORT_MARGIN, x)}px`;
chip.style.top = `${Math.max(CHIP_VIEWPORT_MARGIN, y)}px`;
};
return {
update,
end() {
chip.remove();
doc.body.classList.remove(TABLE_REFERENCE_DRAGGING_CLASS);
if (doc.body.style.cursor === "copy") doc.body.style.cursor = "";
},
};
}