fix(data-grid): scope SQL extractors to active context

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-17 01:51:19 +08:00
1 parent 50e6476cb0
commit 19e547fe52
4 files changed
+143 -9

No files matched your search

@@ -112,6 +112,7 @@ function createExportState(
visibleColumnIndexes?: number[],
isSyntheticContext = false,
contextRowId?: number | null,
contextColumn?: number,
) {
const rows = (rowDataList ?? [rowData ?? columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))]).map((data, index) => ({ ...row(data), id: index + 1 }));
const resolvedContextRowId = contextRowId === undefined ? (rows[0]?.id ?? null) : contextRowId;
@@ -137,7 +138,7 @@ function createExportState(
selectedCells: computed(() => selectedCellMatrix ?? selectedCellsOverride ?? { columns: [], rows: [] }),
selectedCellMatrix: computed(() => selectedCellMatrix ?? null),
selectedRange: computed(() => null),
contextCell: ref(resolvedContextRowId === null ? null : { rowId: resolvedContextRowId, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }),
contextCell: ref(resolvedContextRowId === null ? null : { rowId: resolvedContextRowId, rowIndex: 0, col: contextColumn ?? (isSyntheticContext ? 0 : -1) }),
contextSelectionIsSynthetic: ref(isSyntheticContext),
getRowItem: (rowId) => rows.find((candidate) => candidate.id === rowId),
selectedRowIds,
@@ -288,6 +289,42 @@ describe("useDataGridExport prepared row statements", () => {
);
});
it("uses the right-clicked cell for SQL SELECT even when its row is selected", async () => {
const state = createExportState(editableTable, ["id", "name"], undefined, [7, "Ada"], undefined, undefined, [1], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1);
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("uses only the right-clicked cell for a WHERE clause", async () => {
const state = createExportState(editableTable, ["id", "name"], undefined, [7, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, true, 1, 1);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "name = 'Ada'", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 });
expect(state.canCopyWithExtractor("where-clause")).toBe(true);
await state.copyWithExtractor("where-clause");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
extractor: "where-clause",
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 = {
@@ -403,6 +440,17 @@ describe("useDataGridExport prepared row statements", () => {
});
expect(state.canCopyWithExtractor("sql-select")).toBe(true);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM users WHERE id = 7;", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 });
return state.copyWithExtractor("sql-select").then(() => {
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
columns: [expect.objectContaining({ displayName: "id", sourceName: "id" })],
selectedColumnIndexes: [0],
rows: [[7]],
}),
);
});
});
it("enables INSERT/UPDATE when right-clicking a previously selected primary-key cell", () => {
@@ -854,6 +902,40 @@ describe("useDataGridExport prepared row statements", () => {
expect(toast).toHaveBeenCalledWith("grid.copyExtractorUnsupportedSelection", 5000);
});
it("uses the right-clicked cell for SQL predicates despite an irregular discrete selection", async () => {
const state = createExportState(
editableTable,
["id", "name"],
undefined,
undefined,
{ columns: ["id", "name"], rows: [[1], ["Grace"]] },
[
[1, "Ada"],
[2, "Grace"],
],
[],
DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
false,
undefined,
false,
2,
1,
);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "name = 'Grace'", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 });
expect(state.canCopyWithExtractor("where-clause")).toBe(true);
await expect(state.copyWithExtractor("where-clause")).resolves.toBe(true);
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
extractor: "where-clause",
columns: [expect.objectContaining({ sourceName: "name" })],
selectedColumnIndexes: [0],
rows: [["Grace"]],
}),
);
});
it("limits live extractor previews to the first 100 selected rows", async () => {
const rows = Array.from({ length: 101 }, (_, index) => [index + 1, `name-${index + 1}`]);
const matrix: CellSelectionMatrix = {
@@ -79,6 +79,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return options.hasCellSelection.value ? options.selectedCells.value : null;
}
function hasContextPredicateTarget(extractor: DataGridCopyExtractorId): boolean {
return (extractor === "sql-select" || extractor === "where-clause") && (options.contextCell.value?.col ?? -1) >= 0;
}
function buildRequest(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): DataGridExtractRequest | null {
const visibleIndexes = options.visibleColumnIndexes.value;
const sourceNames = options.allSourceColumns.value;
@@ -91,9 +95,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const matrix = options.selectedCellMatrix.value;
const isMultiCellSelection = !!matrix && (matrix.rowIndexes.length > 1 || matrix.columnIndexes.length > 1);
const contextCell = options.contextCell.value;
// A right-click sets contextCell.col to the clicked column (≥ 0); the test
// harness and non-right-click paths leave it at -1.
const hasRightClickContext = !!options.contextCell.value && options.contextSelectionIsSynthetic.value;
const hasRightClickContext = !!contextCell && options.contextSelectionIsSynthetic.value;
// SQL predicates generated from the context menu must describe the cell the
// user right-clicked, even when an existing row or range selection remains
// active underneath that menu.
const contextPredicateCell = hasContextPredicateTarget(extractor) ? contextCell : null;
// When the user already has a single-cell selection and right-clicks the same
// cell, contextSelectionIsSynthetic is false but we still have a valid context
// cell. For INSERT/UPDATE extractors, we include all visible non-PK columns
@@ -102,7 +111,15 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
// the selected cell via the matrix branch below.
const hasNonSyntheticContextCell = !!options.contextCell.value && options.contextCell.value.col >= 0 && !options.contextSelectionIsSynthetic.value && !!matrix && !isMultiCellSelection;
if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) {
if (contextPredicateCell) {
const item = fullItemsById.get(contextPredicateCell.rowId);
const sourceIndex = visibleIndexes[contextPredicateCell.col];
if (item && !item.isDraft && sourceIndex !== undefined) {
sourceRows = [item.data];
sourceRowIds = [item.id];
selectedSourceIndexes = [sourceIndex];
}
} else if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) {
const items = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft);
sourceRows = items.map((item) => (fullItemsById.get(item.id) ?? item).data);
sourceRowIds = items.map((item) => item.id);
@@ -156,7 +173,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const columnTypesBySource = new Map(visibleIndexes.map((sourceIndex, visibleIndex) => [sourceIndex, options.columnTypes.value?.[visibleIndex]]));
const columns = requiredSourceIndexes.map((sourceIndex, compactIndex) => ({
displayName: fullColumns[sourceIndex],
sourceName: sourceNames?.[sourceIndex],
// Query results without source metadata still expose their database column
// names as display names. Keep the request mapping aligned with the
// availability check so SQL SELECT can use that safe fallback.
sourceName: sourceNames?.[sourceIndex] ?? fullColumns[sourceIndex],
sourceIndex: compactIndex,
}));
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
@@ -227,7 +247,8 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
function canCopyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): boolean {
if (hasUnsupportedDiscreteSelection.value) return false;
const contextPredicateTarget = hasContextPredicateTarget(extractor);
if (hasUnsupportedDiscreteSelection.value && !contextPredicateTarget) return false;
if (!options.hasRowSelection.value && !options.hasCellSelection.value && !options.contextCell.value) return false;
if (extractorUnavailableForDatabase(extractor, options.databaseType.value)) return false;
if (extractor === "sql-inserts") {
@@ -244,7 +265,9 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
if (extractor === "sql-select") {
const matrix = options.selectedCellMatrix.value;
if (options.hasRowSelection.value) {
if (contextPredicateTarget) {
// The context-menu target is validated by buildRequest below.
} else 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;
@@ -257,6 +280,9 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
if (request.selectionKind === "cells" && request.selectedColumnIndexes.length !== 1) return false;
return request.columns.length > 0 && request.columns.every((column) => !!(column.sourceName ?? column.displayName)?.trim());
}
if (extractor === "where-clause" && contextPredicateTarget) {
return buildRequest(extractor, extractorOptions) !== null;
}
if (extractor === "raw") {
const request = buildRequest(extractor, extractorOptions);
return request !== null && request.rows.length * request.selectedColumnIndexes.length === 1;
@@ -278,7 +304,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
async function copyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise<boolean> {
if (hasUnsupportedDiscreteSelection.value) {
if (hasUnsupportedDiscreteSelection.value && !hasContextPredicateTarget(extractor)) {
toast(t("grid.copyExtractorUnsupportedSelection"), 5000);
return false;
}
@@ -317,7 +343,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
async function previewWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions): Promise<DataGridExtractPreview> {
if (hasUnsupportedDiscreteSelection.value) throw new Error(t("grid.copyExtractorUnsupportedSelection"));
if (hasUnsupportedDiscreteSelection.value && !hasContextPredicateTarget(extractor)) throw new Error(t("grid.copyExtractorUnsupportedSelection"));
const initialRequest = buildRequest(extractor, extractorOptions);
if (!initialRequest) throw new Error(t("grid.copyExtractorEmptySelection"));
const sourceRowCount = initialRequest.rows.length;