feat(grid): copy selected data as SELECT

Closes #5591
This commit is contained in:
t8y2 committed 2026-08-14 01:47:05 +00:00
1 parent c892c8d1b0
commit 278429ab07
18 files changed
+337 -11

No files matched your search

@@ -6453,6 +6453,7 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
"sql-in-list": t("grid.copyExtractorSqlInList"),
"sql-inserts": t("grid.copyExtractorSqlInserts"),
"sql-updates": t("grid.copyExtractorSqlUpdates"),
"sql-select": t("grid.copyExtractorSqlSelect"),
"where-clause": t("grid.copyExtractorWhereClause"),
markdown: "Markdown",
html: "HTML",
@@ -264,6 +264,106 @@ describe("useDataGridExport prepared row statements", () => {
expect(copyToClipboard).toHaveBeenCalledWith("UPDATE users SET active = TRUE WHERE id = 7;");
});
it("builds a SELECT request for exactly one explicitly selected cell", async () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["name"],
rows: [["Ada"]],
};
const state = createExportState(editableTable, ["id", "name"], matrix, [7, "Ada"]);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM users WHERE name = 'Ada';", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 });
expect(state.canCopyWithExtractor("sql-select")).toBe(true);
await state.copyWithExtractor("sql-select");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
extractor: "sql-select",
columns: [expect.objectContaining({ sourceName: "name" })],
selectedColumnIndexes: [0],
rows: [["Ada"]],
selectionKind: "cells",
}),
);
});
it("includes hidden identity support columns in a selected-row SELECT request", async () => {
const item = row([7, "Ada"]);
const options: UseDataGridExportOptions = {
columns: computed(() => ["name"]),
displayItems: computed(() => [{ ...item, data: ["Ada"] }]),
allColumns: computed(() => ["id", "name"]),
allDisplayItems: computed(() => [item]),
allSourceColumns: computed(() => ["id", "name"]),
visibleColumnIndexes: computed(() => [1]),
sql: computed(() => undefined),
tableMeta: computed(() => editableTable),
databaseType: computed(() => "mysql"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
context: computed(() => "table-data"),
sourceColumns: computed(() => ["name"]),
columnTypes: computed(() => ["varchar"]),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedCellMatrix: computed(() => null),
selectedRange: computed(() => null),
contextCell: ref(null),
contextSelectionIsSynthetic: ref(false),
getRowItem: (rowId) => (rowId === item.id ? item : undefined),
selectedRowIds: ref(new Set([item.id])),
hasRowSelection: computed(() => true),
};
const state = useDataGridExport(options);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM users WHERE id = 7;", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 });
expect(state.canCopyWithExtractor("sql-select")).toBe(true);
await state.copyWithExtractor("sql-select");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
extractor: "sql-select",
columns: [expect.objectContaining({ sourceName: "id" })],
selectedColumnIndexes: [0],
rows: [[7]],
selectionKind: "rows",
}),
);
});
it("falls back to every source column when a selected-row identity is NULL", async () => {
const state = createExportState(editableTable, ["id", "name"], undefined, [null, "Ada"], undefined, undefined, [1]);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM users WHERE id IS NULL AND name = 'Ada';", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 2 });
expect(state.canCopyWithExtractor("sql-select")).toBe(true);
await state.copyWithExtractor("sql-select");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
extractor: "sql-select",
columns: [expect.objectContaining({ sourceName: "id" }), expect.objectContaining({ sourceName: "name" })],
selectedColumnIndexes: [0, 1],
rows: [[null, "Ada"]],
selectionKind: "rows",
}),
);
});
it("disables SELECT copy for multiple cells or a missing table target", () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [0, 1],
columns: ["id", "name"],
rows: [[7, "Ada"]],
};
expect(createExportState(editableTable, ["id", "name"], matrix, [7, "Ada"]).canCopyWithExtractor("sql-select")).toBe(false);
expect(createExportState({ ...editableTable, tableName: "" }, ["id", "name"], { ...matrix, columnIndexes: [1], columns: ["name"], rows: [["Ada"]] }, [7, "Ada"]).canCopyWithExtractor("sql-select")).toBe(false);
});
it("preserves drag-reordered visible column order when copying", async () => {
vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/csv", fileExtension: "csv", rowCount: 1, columnCount: 3 });
const matrix: CellSelectionMatrix = {
@@ -165,6 +165,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
copyInsertTargetLabel,
sourceColumns,
databaseType,
identifierQuote,
connectionId,
database,
context,
@@ -592,6 +593,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
columnTypes,
extractorOptions: extractorOptionsOption,
databaseType,
identifierQuote,
tableMeta,
hasCellSelection,
selectedCells,
@@ -38,6 +38,7 @@ interface UseDataGridExtractorOptions {
columnTypes: ComputedRef<Array<string | undefined> | undefined>;
extractorOptions?: ComputedRef<DataGridExtractorOptions>;
databaseType: ComputedRef<DatabaseType | undefined>;
identifierQuote?: ComputedRef<string | undefined>;
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
hasCellSelection: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
@@ -108,13 +109,18 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
selectedSourceIndexes = dedupeColumnIndexes(matrix.columnIndexes.map((index) => visibleIndexes[index] ?? index)).filter((index) => index < fullColumns.length);
if (options.hasColumnSelection.value) selectionKind = "columns";
} else if (hasRightClickContext && options.contextCell.value) {
// Right-click on a cell with no (or synthetic single-cell) selection: copy the full row.
// A SELECT targets the clicked cell; existing extractors keep their full-row context behavior.
const item = fullItemsById.get(options.contextCell.value.rowId);
if (item && !item.isDraft) {
sourceRows = [item.data];
sourceRowIds = [item.id];
selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length);
selectionKind = "rows";
if (extractor === "sql-select") {
const sourceIndex = visibleIndexes[options.contextCell.value.col];
selectedSourceIndexes = sourceIndex === undefined ? [] : [sourceIndex];
} else {
selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length);
selectionKind = "rows";
}
}
} else if (matrix) {
// Single-cell matrix without a context cell — still honor the explicit selection.
@@ -127,7 +133,13 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
if (sourceRows.length === 0 || selectedSourceIndexes.length === 0) return null;
const requiredSourceIndexes = [...selectedSourceIndexes];
if (extractor === "sql-updates") {
if (extractor === "sql-select" && selectionKind === "rows") {
const primaryKeys = options.tableMeta.value?.primaryKeys ?? [];
const identityIndexes = primaryKeys.map((primaryKey) => fullColumns.findIndex((displayName, index) => normalizeName(sourceNames?.[index] ?? displayName) === normalizeName(primaryKey)));
const hasCompleteIdentity = primaryKeys.length > 0 && identityIndexes.every((index) => index >= 0) && sourceRows.every((row) => identityIndexes.every((index) => row[index] !== null && row[index] !== undefined));
selectedSourceIndexes = hasCompleteIdentity ? identityIndexes : [...fullColumns.keys()];
requiredSourceIndexes.splice(0, requiredSourceIndexes.length, ...selectedSourceIndexes);
} else if (extractor === "sql-updates") {
for (const primaryKey of options.tableMeta.value?.primaryKeys ?? []) {
const primaryKeyIndex = fullColumns.findIndex((displayName, index) => normalizeName(sourceNames?.[index] ?? displayName) === normalizeName(primaryKey));
if (primaryKeyIndex >= 0 && !requiredSourceIndexes.includes(primaryKeyIndex)) requiredSourceIndexes.push(primaryKeyIndex);
@@ -154,6 +166,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
version: DATA_GRID_EXTRACTOR_CONTRACT_VERSION,
extractor,
databaseType: descriptor.category === "sql" ? options.databaseType.value : undefined,
identifierQuote: descriptor.category === "sql" ? options.identifierQuote?.value : undefined,
tableMeta,
columns,
selectedColumnIndexes,
@@ -222,6 +235,21 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
return canBuildSqlUpdateRequest();
}
if (extractor === "sql-select") {
const matrix = options.selectedCellMatrix.value;
if (options.hasRowSelection.value) {
if (options.selectedRowIds.value.size !== 1) return false;
} else if (matrix) {
if (matrix.rowIndexes.length !== 1 || matrix.columnIndexes.length !== 1) return false;
} else if (!options.contextCell.value || !options.contextSelectionIsSynthetic.value) {
return false;
}
const request = buildRequest(extractor, extractorOptions);
if (!request?.tableMeta?.tableName.trim() || request.rows.length !== 1) return false;
if (request.selectionKind === "columns") return false;
if (request.selectionKind === "cells" && request.selectedColumnIndexes.length !== 1) return false;
return request.columns.length > 0 && request.columns.every((column) => !!column.sourceName?.trim());
}
if (extractor === "raw") {
const request = buildRequest(extractor, extractorOptions);
return request !== null && request.rows.length * request.selectedColumnIndexes.length === 1;
+1
View File
@@ -1485,6 +1485,7 @@ export default {
copyExtractorSqlInsertsWithPrimaryKeys: "SQL Inserts (with Primary Keys)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL Inserts (without Primary Keys)",
copyExtractorSqlUpdates: "SQL Updates",
copyExtractorSqlSelect: "SQL Select",
copyExtractorWhereClause: "Where Clause",
copyExtractorPipeSeparated: "Pipe-separated",
copyExtractorPretty: "Pretty Table",
+1
View File
@@ -1818,6 +1818,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "Sentencias SQL INSERT (con claves primarias)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "Sentencias SQL INSERT (sin claves primarias)",
copyExtractorSqlUpdates: "Sentencias SQL UPDATE",
copyExtractorSqlSelect: "Sentencia SQL SELECT",
copyExtractorWhereClause: "Cláusula WHERE",
copyExtractorPipeSeparated: "Separado por barra vertical",
copyExtractorPretty: "Tabla formateada",
+1
View File
@@ -1816,6 +1816,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "Istruzioni SQL INSERT (con chiavi primarie)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "Istruzioni SQL INSERT (senza chiavi primarie)",
copyExtractorSqlUpdates: "Istruzioni SQL UPDATE",
copyExtractorSqlSelect: "Istruzione SQL SELECT",
copyExtractorWhereClause: "Clausola WHERE",
copyExtractorPipeSeparated: "Separato da pipe",
copyExtractorPretty: "Testo tabellare",
+1
View File
@@ -1838,6 +1838,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 文(主キーを含む)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT 文(主キーを除外)",
copyExtractorSqlUpdates: "SQL UPDATE 文",
copyExtractorSqlSelect: "SQL SELECT 文",
copyExtractorWhereClause: "WHERE 条件",
copyExtractorPipeSeparated: "パイプ区切り",
copyExtractorPretty: "テーブルテキスト",
+1
View File
@@ -1371,6 +1371,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT (기본 키 포함)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT (기본 키 제외)",
copyExtractorSqlUpdates: "SQL UPDATE",
copyExtractorSqlSelect: "SQL SELECT",
copyExtractorWhereClause: "WHERE 절",
copyExtractorPipeSeparated: "파이프(|) 구분",
copyExtractorPretty: "보기 좋은 표",
+1
View File
@@ -1818,6 +1818,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "Instruções SQL INSERT (com chaves primárias)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "Instruções SQL INSERT (sem chaves primárias)",
copyExtractorSqlUpdates: "Instruções SQL UPDATE",
copyExtractorSqlSelect: "Instrução SQL SELECT",
copyExtractorWhereClause: "Condição WHERE",
copyExtractorPipeSeparated: "Separado por pipe",
copyExtractorPretty: "Tabela formatada",
+1
View File
@@ -1409,6 +1409,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 语句(含主键)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT 语句(不含主键)",
copyExtractorSqlUpdates: "SQL UPDATE 语句",
copyExtractorSqlSelect: "SQL SELECT 语句",
copyExtractorWhereClause: "WHERE 条件",
copyExtractorPipeSeparated: "竖线分隔",
copyExtractorPretty: "表格文本",
+1
View File
@@ -1822,6 +1822,7 @@ export default withEnglishFallback({
copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 語句(含主鍵)",
copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT 語句(不含主鍵)",
copyExtractorSqlUpdates: "SQL UPDATE 語句",
copyExtractorSqlSelect: "SQL SELECT 語句",
copyExtractorWhereClause: "WHERE 條件",
copyExtractorPipeSeparated: "豎線分隔",
copyExtractorPretty: "表格文本",
@@ -1,7 +1,7 @@
import type { DatabaseType } from "@/types/database";
import type { DataGridCopyInsertMode, DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
export const DATA_GRID_COPY_EXTRACTOR_IDS = ["raw", "tsv", "tsv-with-headers", "csv", "csv-with-headers", "pipe-separated", "dsv", "json", "json-lines", "one-row", "sql-in-list", "sql-inserts", "sql-updates", "where-clause", "markdown", "html", "xml", "pretty"] as const;
export const DATA_GRID_COPY_EXTRACTOR_IDS = ["raw", "tsv", "tsv-with-headers", "csv", "csv-with-headers", "pipe-separated", "dsv", "json", "json-lines", "one-row", "sql-in-list", "sql-inserts", "sql-updates", "sql-select", "where-clause", "markdown", "html", "xml", "pretty"] as const;
export type DataGridCopyExtractorId = (typeof DATA_GRID_COPY_EXTRACTOR_IDS)[number];
export type DataGridCopyPreference = "smart" | Exclude<DataGridCopyExtractorId, "raw">;
@@ -44,6 +44,7 @@ export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record<DataGridCopyExtractorI
"sql-in-list": { category: "sql", separatorBefore: true },
"sql-inserts": { category: "sql" },
"sql-updates": { category: "sql" },
"sql-select": { category: "sql" },
"where-clause": { category: "sql" },
markdown: { category: "document", separatorBefore: true },
html: { category: "document" },
@@ -65,7 +66,7 @@ const NO_WHERE_CLAUSE_DATABASE_TYPES: ReadonlyArray<string> = ["neo4j", "tdengin
export function extractorUnavailableForDatabase(extractor: DataGridCopyExtractorId, databaseType: string | undefined | null): boolean {
const db = databaseType ?? "";
if (extractor === "sql-updates") return NO_SQL_UPDATE_DATABASE_TYPES.includes(db);
if (extractor === "where-clause") return NO_WHERE_CLAUSE_DATABASE_TYPES.includes(db);
if (extractor === "where-clause" || extractor === "sql-select") return NO_WHERE_CLAUSE_DATABASE_TYPES.includes(db);
return false;
}
@@ -100,6 +101,7 @@ export interface DataGridExtractRequest {
version: typeof DATA_GRID_EXTRACTOR_CONTRACT_VERSION;
extractor: DataGridCopyExtractorId;
databaseType?: DatabaseType;
identifierQuote?: string;
tableMeta?: DataGridTableMeta;
columns: DataGridExtractColumn[];
selectedColumnIndexes: number[];