diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index eaa72a1b79..cc06a70a3b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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", diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 30adb431b2..0f6741e341 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -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 = { diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 0708108237..e8f7bdeac4 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -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, diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index bb1aa2f986..809d080ccb 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -38,6 +38,7 @@ interface UseDataGridExtractorOptions { columnTypes: ComputedRef | undefined>; extractorOptions?: ComputedRef; databaseType: ComputedRef; + identifierQuote?: ComputedRef; tableMeta: ComputedRef; hasCellSelection: ComputedRef; selectedCells: ComputedRef; @@ -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; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f28dd8c55e..21d51a0a58 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 68bcc3f70b..f4d7ab5626 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3b7e5bfba7..add137c2bf 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 2ad1e98337..64643c47c4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1838,6 +1838,7 @@ export default withEnglishFallback({ copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 文(主キーを含む)", copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT 文(主キーを除外)", copyExtractorSqlUpdates: "SQL UPDATE 文", + copyExtractorSqlSelect: "SQL SELECT 文", copyExtractorWhereClause: "WHERE 条件", copyExtractorPipeSeparated: "パイプ区切り", copyExtractorPretty: "テーブルテキスト", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 480778d921..95ac07c2aa 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1371,6 +1371,7 @@ export default withEnglishFallback({ copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT (기본 키 포함)", copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT (기본 키 제외)", copyExtractorSqlUpdates: "SQL UPDATE", + copyExtractorSqlSelect: "SQL SELECT", copyExtractorWhereClause: "WHERE 절", copyExtractorPipeSeparated: "파이프(|) 구분", copyExtractorPretty: "보기 좋은 표", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 43a2c9f085..a4b6aa69e9 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 182a81ba19..08d78d61f9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1409,6 +1409,7 @@ export default withEnglishFallback({ copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 语句(含主键)", copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT 语句(不含主键)", copyExtractorSqlUpdates: "SQL UPDATE 语句", + copyExtractorSqlSelect: "SQL SELECT 语句", copyExtractorWhereClause: "WHERE 条件", copyExtractorPipeSeparated: "竖线分隔", copyExtractorPretty: "表格文本", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 355bc7dc38..5f1f21675e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1822,6 +1822,7 @@ export default withEnglishFallback({ copyExtractorSqlInsertsWithPrimaryKeys: "SQL INSERT 語句(含主鍵)", copyExtractorSqlInsertsWithoutPrimaryKeys: "SQL INSERT 語句(不含主鍵)", copyExtractorSqlUpdates: "SQL UPDATE 語句", + copyExtractorSqlSelect: "SQL SELECT 語句", copyExtractorWhereClause: "WHERE 條件", copyExtractorPipeSeparated: "豎線分隔", copyExtractorPretty: "表格文本", diff --git a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts index d382a91834..584a56793f 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts @@ -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; @@ -44,6 +44,7 @@ export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record = ["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[]; diff --git a/crates/dbx-core/src/data_grid_extractors/contract.rs b/crates/dbx-core/src/data_grid_extractors/contract.rs index 57b5a91ced..589cde028a 100644 --- a/crates/dbx-core/src/data_grid_extractors/contract.rs +++ b/crates/dbx-core/src/data_grid_extractors/contract.rs @@ -31,6 +31,7 @@ pub enum DataGridExtractorId { SqlInList, SqlInserts, SqlUpdates, + SqlSelect, WhereClause, Markdown, Html, @@ -147,6 +148,8 @@ pub struct DataGridExtractRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier_quote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub table_meta: Option, #[serde(default)] pub columns: Vec, @@ -198,6 +201,7 @@ pub enum DataGridExtractErrorCode { UnsupportedVersion, EmptySelection, InvalidRawSelection, + InvalidSelectSelection, InvalidColumnIndex, InvalidColumnMapping, MissingTableMetadata, diff --git a/crates/dbx-core/src/data_grid_extractors/mod.rs b/crates/dbx-core/src/data_grid_extractors/mod.rs index 45100512b7..3ea218c469 100644 --- a/crates/dbx-core/src/data_grid_extractors/mod.rs +++ b/crates/dbx-core/src/data_grid_extractors/mod.rs @@ -14,7 +14,7 @@ use delimited::{write_dsv, write_one_row}; use document::{write_html, write_markdown, write_pretty, write_xml}; use json::{write_json, write_json_lines}; use raw::write_raw; -use sql::{write_sql_in_list, write_sql_inserts, write_sql_updates, write_where_clause}; +use sql::{write_sql_in_list, write_sql_inserts, write_sql_select, write_sql_updates, write_where_clause}; const DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; const DATA_GRID_EXTRACTOR_MAX_INPUT_BYTES: usize = 64 * 1024 * 1024; @@ -319,6 +319,10 @@ fn write_extraction( } DataGridExtractorId::SqlInserts => write_sql_inserts(context, output), DataGridExtractorId::SqlUpdates => write_sql_updates(context, output), + DataGridExtractorId::SqlSelect => { + write_sql_select(context, output)?; + Ok(text_metadata("application/sql", "sql")) + } DataGridExtractorId::WhereClause => { write_where_clause(context, output)?; Ok(text_metadata("application/sql", "sql")) diff --git a/crates/dbx-core/src/data_grid_extractors/sql.rs b/crates/dbx-core/src/data_grid_extractors/sql.rs index f3eb4df492..aced2d086d 100644 --- a/crates/dbx-core/src/data_grid_extractors/sql.rs +++ b/crates/dbx-core/src/data_grid_extractors/sql.rs @@ -5,9 +5,9 @@ use super::{ }; use crate::data_grid_sql::{ build_column_predicate, build_data_grid_copy_insert_statement, build_data_grid_copy_update_statements, - format_grid_sql_literal, is_auto_generated_column, is_grid_insert_omitted_column, is_non_identity_generated_column, - supports_relational_copy_predicates, DataGridCopyInsertStatementOptions, DataGridCopyUpdateStatementOptions, - DataGridTableMeta, + data_grid_qualified_table_name, format_grid_sql_literal, is_auto_generated_column, is_grid_insert_omitted_column, + is_non_identity_generated_column, supports_relational_copy_predicates, DataGridCopyInsertStatementOptions, + DataGridCopyUpdateStatementOptions, DataGridTableMeta, }; use serde_json::Value; use std::collections::HashSet; @@ -312,6 +312,102 @@ pub(super) fn write_where_clause( Ok(()) } +pub(super) fn write_sql_select( + context: &ExtractContext<'_>, + output: &mut dyn Write, +) -> Result<(), DataGridExtractError> { + ensure_sql_builder_budget(context)?; + if !supports_relational_copy_predicates(context.request.database_type) { + return Err(DataGridExtractError::new( + DataGridExtractErrorCode::UnsupportedDatabase, + "SELECT extraction is not supported for this database type.", + )); + } + let table_meta = + context.request.table_meta.as_ref().filter(|meta| !meta.table_name.trim().is_empty()).ok_or_else(|| { + DataGridExtractError::new( + DataGridExtractErrorCode::MissingTableMetadata, + "SELECT extraction requires one resolved table target.", + ) + })?; + if context.request.rows.len() != 1 + || matches!(context.request.selection_kind, super::DataGridSelectionKind::Columns) + || (context.request.selection_kind == super::DataGridSelectionKind::Cells + && context.selected_columns.len() != 1) + { + return Err(DataGridExtractError::new( + DataGridExtractErrorCode::InvalidSelectSelection, + "SELECT extraction supports exactly one selected cell or row.", + )); + } + + let row = &context.request.rows[0]; + let predicate_columns = if context.request.selection_kind == super::DataGridSelectionKind::Rows { + let identity = table_meta + .primary_keys + .iter() + .map(|primary_key| { + context + .request + .columns + .iter() + .find(|column| { + normalized_name_eq(column.source_name.as_deref().unwrap_or(&column.display_name), primary_key) + }) + .filter(|column| row.get(column.source_index).is_some_and(|value| !value.is_null())) + }) + .collect::>>(); + identity.filter(|columns| !columns.is_empty()).unwrap_or_else(|| context.request.columns.iter().collect()) + } else { + context.selected_columns.clone() + }; + if predicate_columns.is_empty() { + return Err(DataGridExtractError::new( + DataGridExtractErrorCode::InvalidColumnMapping, + "SELECT extraction has no source columns for its predicate.", + )); + } + + let mut predicates = Vec::with_capacity(predicate_columns.len()); + for column in predicate_columns { + let value = row.get(column.source_index).ok_or_else(|| { + DataGridExtractError::new( + DataGridExtractErrorCode::InvalidColumnMapping, + format!("Column '{}' maps to missing row index {}.", column.display_name, column.source_index), + ) + })?; + let source_name = column.source_name.as_deref().filter(|name| !name.trim().is_empty()).ok_or_else(|| { + DataGridExtractError::new( + DataGridExtractErrorCode::InvalidColumnMapping, + format!("Column '{}' has no resolved source-column mapping.", column.display_name), + ) + })?; + let column_info = table_meta + .columns + .as_deref() + .unwrap_or_default() + .iter() + .find(|info| normalized_name_eq(&info.name, source_name)); + predicates.push(build_column_predicate( + context.request.database_type, + source_name, + value, + column_info, + true, + context.request.identifier_quote.as_deref(), + )); + } + let table = data_grid_qualified_table_name( + context.request.database_type, + table_meta.catalog.as_deref(), + table_meta.schema.as_deref(), + table_meta.database.as_deref(), + &table_meta.table_name, + context.request.identifier_quote.as_deref(), + ); + write_bytes(output, format!("SELECT * FROM {table} WHERE {};", predicates.join(" AND ")).as_bytes()) +} + fn sql_metadata(omitted_columns: Vec) -> WriteMetadata { let warnings = if omitted_columns.is_empty() { Vec::new() diff --git a/crates/dbx-core/src/data_grid_extractors/tests.rs b/crates/dbx-core/src/data_grid_extractors/tests.rs index 68630206c4..d06b1c6bef 100644 --- a/crates/dbx-core/src/data_grid_extractors/tests.rs +++ b/crates/dbx-core/src/data_grid_extractors/tests.rs @@ -12,6 +12,7 @@ fn request(extractor: DataGridExtractorId) -> DataGridExtractRequest { version: DATA_GRID_EXTRACTOR_CONTRACT_VERSION, extractor, database_type: Some(DatabaseType::Postgres), + identifier_quote: None, table_meta: None, columns: vec![column("id", 0), column("name", 1)], selected_column_indexes: vec![0, 1], @@ -358,6 +359,86 @@ fn builds_null_safe_where_clause_predicates() { assert_eq!(result.text, "\"name\" IS NULL"); } +#[test] +fn builds_select_for_one_explicit_cell() { + let mut request = request(DataGridExtractorId::SqlSelect); + request.table_meta = Some(DataGridTableMeta { + catalog: None, + database: None, + schema: Some("public".to_string()), + table_name: "users".to_string(), + primary_keys: vec!["id".to_string()], + columns: None, + }); + request.selected_column_indexes = vec![1]; + request.rows = vec![vec![json!(7), json!("Ada")]]; + + let result = extract_data_grid_selection(request).expect("SELECT extraction"); + + assert_eq!(result.text, "SELECT * FROM \"public\".\"users\" WHERE \"name\" = 'Ada';"); +} + +#[test] +fn select_row_uses_complete_identity_including_hidden_columns() { + let mut request = request(DataGridExtractorId::SqlSelect); + request.table_meta = Some(DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "users".to_string(), + primary_keys: vec!["tenant_id".to_string(), "id".to_string()], + columns: None, + }); + request.columns = vec![column("name", 0), column("id", 1), column("tenant_id", 2)]; + request.selected_column_indexes = vec![0]; + request.rows = vec![vec![json!("Ada"), json!(7), json!(9)]]; + request.selection_kind = DataGridSelectionKind::Rows; + + let result = extract_data_grid_selection(request).expect("SELECT extraction"); + + assert_eq!(result.text, "SELECT * FROM \"users\" WHERE \"tenant_id\" = 9 AND \"id\" = 7;"); +} + +#[test] +fn select_row_falls_back_to_all_columns_without_usable_identity() { + for primary_keys in [Vec::new(), vec!["id".to_string()]] { + let mut request = request(DataGridExtractorId::SqlSelect); + request.table_meta = Some(DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "users".to_string(), + primary_keys, + columns: None, + }); + request.rows = vec![vec![Value::Null, json!("Ada")]]; + request.selection_kind = DataGridSelectionKind::Rows; + + let result = extract_data_grid_selection(request).expect("SELECT extraction"); + + assert_eq!(result.text, "SELECT * FROM \"users\" WHERE \"id\" IS NULL AND \"name\" = 'Ada';"); + } +} + +#[test] +fn select_rejects_ambiguous_selection_and_missing_target() { + let error = extract_data_grid_selection(request(DataGridExtractorId::SqlSelect)) + .expect_err("multiple cells without a table must fail"); + assert_eq!(error.code, DataGridExtractErrorCode::MissingTableMetadata); + + let mut request = request(DataGridExtractorId::SqlSelect); + request.table_meta = Some(DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "users".to_string(), + primary_keys: Vec::new(), + columns: None, + }); + let error = extract_data_grid_selection(request).expect_err("multiple cells must fail"); + assert_eq!(error.code, DataGridExtractErrorCode::InvalidSelectSelection); +} + #[test] fn where_clause_applies_mysql_json_cast() { // WHERE must reuse the UPDATE predicate builder so MySQL JSON columns get diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 64a2005ce8..1f6517a020 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -2857,7 +2857,7 @@ fn data_grid_identifier(database_type: Option, name: &str, identif crate::sql_dialect::quote_table_data_identifier(database_type, name, identifier_quote) } -fn data_grid_qualified_table_name( +pub(crate) fn data_grid_qualified_table_name( database_type: Option, catalog: Option<&str>, schema: Option<&str>,