From aa0abcd6701941ab81f745e6055721d80b70a476 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 9 Jul 2026 14:35:59 +0800 Subject: [PATCH] feat(query): support broader result editing --- apps/desktop/src/components/grid/DataGrid.vue | 20 +- .../src/components/layout/ContentArea.vue | 3 + apps/desktop/src/i18n/locales/en.ts | 4 +- apps/desktop/src/i18n/locales/es.ts | 4 +- apps/desktop/src/i18n/locales/it.ts | 4 +- apps/desktop/src/i18n/locales/ja.ts | 4 +- apps/desktop/src/i18n/locales/pt-BR.ts | 4 +- apps/desktop/src/i18n/locales/zh-CN.ts | 4 +- apps/desktop/src/i18n/locales/zh-TW.ts | 4 +- apps/desktop/src/lib/sql/sqlAnalysis.ts | 281 ++++++--- apps/desktop/src/stores/queryStore.ts | 311 +++++++--- apps/desktop/src/types/database.ts | 18 + crates/dbx-core/src/sql_editability.rs | 531 +++++++++++++----- packages/app-tests/queryStore.test.ts | 287 ++++++++++ packages/app-tests/sqlAnalysis.test.ts | 92 +-- 15 files changed, 1238 insertions(+), 333 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e5d6649c64..287c29d07f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -122,6 +122,7 @@ import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination"; import { dataGridScrollPosition, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; +import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition, combineWhereInputs, filterModeNeedsValue, parseFilterValue } from "@/lib/dataGrid/dataGridColumnFilter"; @@ -230,6 +231,9 @@ const props = defineProps<{ allExportResults?: Array<{ sheetName: string; result: QueryResult }>; exportFileBaseName?: string; customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler; + queryEditabilityReason?: QueryEditabilityReason; + allowInsertRows?: boolean; + allowDeleteRows?: boolean; }>(); const dataGridTraceId = uuid().slice(0, 8); @@ -3503,6 +3507,8 @@ watch( ); const showQueryEditReadyBadge = computed(() => isResultsContext.value && hasData.value && !!props.editable && (!!props.tableMeta || !!props.customSaveHandler)); const queryEditReadyTargetLabel = computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel ?? ""); +const showQueryEditReadOnlyBadge = computed(() => isResultsContext.value && hasData.value && !showQueryEditReadyBadge.value && !!props.queryEditabilityReason); +const queryEditReadOnlyReason = computed(() => (props.queryEditabilityReason ? t(`grid.queryEditUnsupported.${props.queryEditabilityReason}`) : "")); const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? [])); const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value); const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value); @@ -3513,12 +3519,13 @@ const canEditExistingRows = computed(() => !!props.customSaveHandler || canEditE const customReadonlyColumns = computed(() => new Set((props.customSaveHandler?.readonlyColumns ?? []).map((column) => column.toLowerCase()))); const hasDataGridSaveTarget = computed(() => !!props.tableMeta || !!props.customSaveHandler); const hasDataGridInsertTarget = computed(() => { + if (props.allowInsertRows === false) return false; const handler = props.customSaveHandler; if (handler) return handler.supportsInsert === true || handler.canInsert === true; return !!props.tableMeta && canInsertTableRows(props.databaseType); }); const canInsertRows = computed(() => !!props.editable && hasDataGridInsertTarget.value); -const canDeleteRows = computed(() => !props.customSaveHandler || props.customSaveHandler.canDelete !== false); +const canDeleteRows = computed(() => props.allowDeleteRows !== false && (!props.customSaveHandler || props.customSaveHandler.canDelete !== false)); watch( () => [props.databaseType, props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName], async () => { @@ -3937,6 +3944,7 @@ const showDataGridTopbar = computed( canShowWhereSearch.value || hasSearchBarSlot.value || showQueryEditReadyBadge.value || + showQueryEditReadOnlyBadge.value || props.context !== "results" || (!!props.editable && hasDataGridSaveTarget.value) || transactionActive.value || @@ -8823,6 +8831,16 @@ const gridContextMenuItems = computed(() => { {{ t("grid.queryEditReadyHint", { table: queryEditReadyTargetLabel }) }} + + +
+ {{ t("grid.queryEditReadOnly") }} +
+
+ + {{ queryEditReadOnlyReason }} + +
diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index b8eea034f7..929534b5a3 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1091,6 +1091,9 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe :editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler" :source-columns="activeTab.querySourceColumns" :custom-save-handler="mongoQueryResultSaveHandler" + :query-editability-reason="activeTab.queryEditabilityReason" + :allow-insert-rows="activeTab.queryAnalysis?.allowInsertDelete !== false" + :allow-delete-rows="activeTab.queryAnalysis?.allowInsertDelete !== false" context="results" :database-type="activeEffectiveDatabaseType" :connection-id="activeTab.connectionId" diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 46d54aa4ef..5e010d6018 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -972,7 +972,7 @@ export default { keylessEditWarning: "No primary key", keylessEditWarningHint: "This table has no primary key. Updates and deletes use all original row values in the WHERE clause; exact duplicate rows may be affected together.", queryEditReady: "Editable", - queryEditReadyHint: "This single-table result comes from “{table}”. You can edit, delete, or add rows, then use the pending-changes action to save.", + queryEditReadyHint: "Writable cells in this result save back to “{table}”. Use the pending-changes action to review and save.", queryEditReadOnly: "Read-only result", queryEditUnsupported: { "not-select": "Only SELECT query results can be edited directly.", @@ -980,7 +980,7 @@ export default { "set-operation": "UNION, INTERSECT, or EXCEPT results cannot be mapped safely back to source rows.", aggregation: "DISTINCT, GROUP BY, HAVING, and aggregate results cannot be edited directly.", "external-source": "External files and table-function results cannot be written back directly. Import them into a database table before editing.", - "complex-source": "JOINs, multiple tables, and subqueries cannot be mapped safely to one source row.", + "complex-source": "DBX could not identify exactly one safe writeback target table for this result.", "computed-columns": "Computed expressions or function results cannot be written back. Select raw column names instead.", "no-table": "No editable source table was detected.", "no-primary-key": "The target table has no primary key, so rows cannot be updated or deleted safely.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 175ae6a2b1..3472521cdb 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -916,7 +916,7 @@ export default withEnglishFallback({ keylessEditWarning: "Sin clave primaria", keylessEditWarningHint: "Esta tabla no tiene clave primaria. Las actualizaciones y eliminaciones usan todos los valores originales de la fila en WHERE; filas duplicadas exactas pueden verse afectadas juntas.", queryEditReady: "Editable", - queryEditReadyHint: 'Este resultado de una sola tabla proviene de "{table}". Puedes editar, eliminar o agregar filas, luego usa la acción de cambios pendientes para guardar.', + queryEditReadyHint: 'Las celdas editables de este resultado se guardan en "{table}". Usa la acción de cambios pendientes para revisar y guardar.', queryEditReadOnly: "Resultado de solo lectura", queryEditUnsupported: { "not-select": "Solo los resultados de consultas SELECT pueden editarse directamente.", @@ -924,7 +924,7 @@ export default withEnglishFallback({ "set-operation": "Los resultados de UNION, INTERSECT o EXCEPT no pueden asignarse de forma segura a las filas de origen.", aggregation: "Los resultados con DISTINCT, GROUP BY, HAVING y agregaciones no pueden editarse directamente.", "external-source": "Los archivos externos y los resultados de funciones de tabla no pueden escribirse directamente. Impórtalos a una tabla de base de datos antes de editarlos.", - "complex-source": "JOINs, múltiples tablas y subconsultas no pueden asignarse de forma segura a una fila de origen.", + "complex-source": "DBX no pudo identificar exactamente una tabla de destino segura para escribir este resultado.", "computed-columns": "Las expresiones calculadas o los resultados de funciones no pueden escribirse de vuelta. Selecciona nombres de columna originales.", "no-table": "No se detectó ninguna tabla de origen editable.", "no-primary-key": "La tabla de destino no tiene clave primaria, por lo que las filas no pueden actualizarse o eliminarse de forma segura.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d5923bbd74..cf6b5373b6 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -914,7 +914,7 @@ export default withEnglishFallback({ keylessEditWarning: "Nessuna chiave primaria", keylessEditWarningHint: "Questa tabella non ha chiavi primarie. Gli aggiornamenti e le eliminazioni utilizzano tutti i valori originali delle righe nella clausola WHERE; le righe duplicate esatte potrebbero essere influenzate contemporaneamente.", queryEditReady: "Modificabile", - queryEditReadyHint: "Questo risultato a tabella singola proviene da “{table}”. Puoi modificare, eliminare o aggiungere righe, quindi utilizzare l'azione delle modifiche in sospeso per salvare.", + queryEditReadyHint: "Le celle modificabili in questo risultato vengono salvate in “{table}”. Usa l'azione delle modifiche in sospeso per rivedere e salvare.", queryEditReadOnly: "Risultato in sola lettura", queryEditUnsupported: { "not-select": "Only SELECT query results can be edited directly.", @@ -922,7 +922,7 @@ export default withEnglishFallback({ "set-operation": "UNION, INTERSECT, or EXCEPT results cannot be mapped safely back to source rows.", aggregation: "I risultati di DISTINCT, GROUP BY, HAVING e aggregazioni non possono essere modificati direttamente.", "external-source": "External files and table-function results cannot be written back directly. Import them into a database table before editing.", - "complex-source": "JOINs, multiple tables, and subqueries cannot be mapped safely to one source row.", + "complex-source": "DBX could not identify exactly one safe writeback target table for this result.", "computed-columns": "Computed expressions or function results cannot be written back. Select raw column names instead.", "no-table": "No editable source table was detected.", "no-primary-key": "The target table has no primary key, so rows cannot be updated or deleted safely.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 0a4b38692f..95e71637c8 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -908,7 +908,7 @@ export default withEnglishFallback({ keylessEditWarning: "主キーなし", keylessEditWarningHint: "このテーブルには主キーがありません。更新と削除はWHERE句にすべての元の行値を使用します。完全に重複する行はまとめて影響を受ける可能性があります。", queryEditReady: "編集可能", - queryEditReadyHint: "この単一テーブル結果は「{table}」から取得されています。行の編集、削除、追加が可能で、保留中の変更アクションから保存できます。", + queryEditReadyHint: "この結果の書き込み可能なセルは「{table}」へ保存されます。保留中の変更アクションで確認して保存してください。", queryEditReadOnly: "読み取り専用の結果", queryEditUnsupported: { "not-select": "SELECTクエリの結果のみ直接編集できます。", @@ -916,7 +916,7 @@ export default withEnglishFallback({ "set-operation": "UNION、INTERSECT、EXCEPTの結果はソース行に安全にマッピングできません。", aggregation: "DISTINCT、GROUP BY、HAVING、および集計結果は直接編集できません。", "external-source": "外部ファイルとテーブル関数の結果は直接書き戻せません。編集前にデータベーステーブルにインポートしてください。", - "complex-source": "JOIN、複数テーブル、サブクエリは1つのソース行に安全にマッピングできません。", + "complex-source": "DBX はこの結果に対して安全に書き戻せる対象テーブルを 1 つに特定できませんでした。", "computed-columns": "計算式や関数の結果は書き戻せません。代わりに生の列名を選択してください。", "no-table": "編集可能なソーステーブルが検出されませんでした。", "no-primary-key": "対象テーブルに主キーがないため、行を安全に更新または削除できません。", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 42dfde65f1..cadf69445e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -915,7 +915,7 @@ export default withEnglishFallback({ keylessEditWarning: "Sem chave primária", keylessEditWarningHint: "Esta tabela não possui chave primária. Atualizações e exclusões usam todos os valores originais da linha na cláusula WHERE; linhas exatamente duplicadas podem ser afetadas juntas.", queryEditReady: "Editável", - queryEditReadyHint: "Este resultado de tabela única vem de “{table}”. Você pode editar, excluir ou adicionar linhas e, em seguida, usar a ação de alterações pendentes para salvar.", + queryEditReadyHint: "As células editáveis deste resultado são salvas em “{table}”. Use a ação de alterações pendentes para revisar e salvar.", queryEditReadOnly: "Resultado somente leitura", queryEditUnsupported: { "not-select": "Apenas resultados de consultas SELECT podem ser editados diretamente.", @@ -923,7 +923,7 @@ export default withEnglishFallback({ "set-operation": "Resultados de UNION, INTERSECT ou EXCEPT não podem ser mapeados com segurança de volta às linhas de origem.", aggregation: "Resultados de DISTINCT, GROUP BY, HAVING e agregações não podem ser editados diretamente.", "external-source": "Arquivos externos e resultados de funções de tabela não podem ser gravados de volta diretamente. Importe-os para uma tabela do banco de dados antes de editar.", - "complex-source": "JOINs, múltiplas tabelas e subconsultas não podem ser mapeados com segurança para uma única linha de origem.", + "complex-source": "O DBX não conseguiu identificar exatamente uma tabela de destino segura para gravar este resultado.", "computed-columns": "Expressões computadas ou resultados de funções não podem ser gravados de volta. Selecione os nomes brutos das colunas em vez disso.", "no-table": "Nenhuma tabela de origem editável foi detectada.", "no-primary-key": "A tabela de destino não possui chave primária, portanto as linhas não podem ser atualizadas ou excluídas com segurança.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2248358d27..aff7a5e2fc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -974,7 +974,7 @@ export default withEnglishFallback({ keylessEditWarning: "无主键定位", keylessEditWarningHint: "这张表没有主键。保存更新或删除时会使用整行原始值作为 WHERE 条件;如果存在完全重复的行,可能会影响多行。", queryEditReady: "可编辑", - queryEditReadyHint: "这是来自“{table}”的单表结果,可直接修改、删除或新增行,最后点击待保存按钮保存。", + queryEditReadyHint: "此结果中的可写单元格会回写到“{table}”。请通过待保存按钮查看并保存更改。", queryEditReadOnly: "只读结果", queryEditUnsupported: { "not-select": "只有 SELECT 查询结果可以直接编辑。", @@ -982,7 +982,7 @@ export default withEnglishFallback({ "set-operation": "包含 UNION、INTERSECT 或 EXCEPT 的结果无法安全定位原表行。", aggregation: "包含 DISTINCT、GROUP BY、HAVING 或聚合结果,无法直接编辑原始数据。", "external-source": "外部文件或表函数查询结果无法直接回写,请先导入到数据库表后再编辑。", - "complex-source": "包含 JOIN、多个表或子查询的结果无法安全定位单个原表行。", + "complex-source": "无法为此结果唯一确定一个安全的回写目标表。", "computed-columns": "结果列包含表达式或函数,无法安全回写。请查询原始列名。", "no-table": "未识别到可编辑的数据表。", "no-primary-key": "目标表没有主键,无法安全定位要更新或删除的行。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4d39944f77..076fc57fbb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -915,7 +915,7 @@ export default withEnglishFallback({ keylessEditWarning: "無主鍵定位", keylessEditWarningHint: "此資料表沒有主鍵。更新與刪除會在 WHERE 子句中使用所有原始資料列值;完全重複的資料列可能會一起受到影響。", queryEditReady: "可編輯", - queryEditReadyHint: "這是來自「{table}」的單一資料表結果,可直接修改、刪除或新增資料列,最後點選待儲存按鈕儲存。", + queryEditReadyHint: "此結果中的可寫儲存格會回寫到「{table}」。請透過待儲存按鈕檢視並儲存變更。", queryEditReadOnly: "唯讀結果", queryEditUnsupported: { "not-select": "只有 SELECT 查詢結果可以直接編輯。", @@ -923,7 +923,7 @@ export default withEnglishFallback({ "set-operation": "UNION、INTERSECT 或 EXCEPT 結果無法安全映射回來源資料列。", aggregation: "包含 DISTINCT、GROUP BY、HAVING 或聚合結果,無法直接編輯原始資料。", "external-source": "外部檔案和資料表函式結果無法直接回寫。請先匯入資料庫資料表再編輯。", - "complex-source": "JOIN、多個資料表和子查詢無法安全映射到單一來源資料列。", + "complex-source": "DBX 無法為此結果唯一確定一個安全的回寫目標資料表。", "computed-columns": "計算運算式或函式結果無法回寫。請改為選取原始欄位名稱。", "no-table": "未識別到可編輯的資料表。", "no-primary-key": "目標資料表沒有主鍵,因此無法安全地更新或刪除資料列。", diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 3e6e591984..ac4e963c79 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -10,6 +10,8 @@ export function isBinaryType(dataType: string): boolean { } export interface EditableQueryInfo { + catalog?: string; + catalogQuoted?: boolean; schema: string | undefined; schemaQuoted?: boolean; tableName: string; @@ -17,25 +19,46 @@ export interface EditableQueryInfo { tableAlias?: string; selectStar: boolean; columns: EditableQueryColumn[]; // empty array if SELECT * + sources?: EditableQuerySource[]; + editableSourceKey?: string; + multiSource?: boolean; + allowInsertDelete?: boolean; } export interface EditableQueryColumn { sourceName?: string; sourceNameQuoted?: boolean; + sourceQualifier?: string; + sourceKey?: string; + star?: boolean; resultName: string; expression: string; } +export interface EditableQuerySource { + key: string; + catalog?: string; + catalogQuoted?: boolean; + schema?: string; + schemaQuoted?: boolean; + tableName: string; + tableNameQuoted?: boolean; + alias?: string; +} + export type QueryEditabilityReason = "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable"; export type QueryEditability = { editable: true; analysis: EditableQueryInfo } | { editable: false; reason: QueryEditabilityReason }; /** * Parse a SELECT statement to determine if it's editable. - * Only simple single-table SELECT queries are considered editable: - * - No JOIN, GROUP BY, HAVING, UNION, subqueries, CTEs, DISTINCT, aggregations - * - Must have a single FROM clause with one table - * - WHERE, ORDER BY, LIMIT are allowed + * Parse a SELECT statement to determine whether DBX can bind result columns to + * base-table columns. DBeaver uses result metadata for the same idea; DBX has to + * recover enough source mapping from SQL text before table metadata is loaded. + * + * Aggregated/set/query-derived results remain read-only. Multi-source queries + * may still be editable later if metadata proves that exactly one source table + * has a complete row identifier in the returned columns. */ export function analyzeEditableQuery(sql: string): EditableQueryInfo | null { const result = analyzeEditableQueryEditability(sql); @@ -67,45 +90,44 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability { const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"], fromIndex + 4); const fromBody = normalized.slice(fromIndex + 4, fromEnd < 0 ? normalized.length : fromEnd).trim(); if (isExternalFromSource(fromBody)) return { editable: false, reason: "external-source" }; - const source = parseFromSource(fromBody); - if (!source) return { editable: false, reason: "complex-source" }; + const sources = parseFromSources(fromBody); + if (!sources.length) return { editable: false, reason: "complex-source" }; + const source = sources[0]!; - const selectStar = isSelectStar(selectBody, source.alias); - const columns = selectStar ? [] : parseSelectColumns(selectBody); + const selectStar = sources.length === 1 && isSelectStar(selectBody, source.alias); + if (sources.length > 1 && /(^|,)\s*(?:\*|(?:[A-Za-z_][\w$]*|"[^"]+"|`[^`]+`|\[[^\]]+\])\s*\.\s*\*)\s*(?:,|$)/.test(selectBody)) { + return { editable: false, reason: "complex-source" }; + } + const columns = selectStar ? [] : parseSelectColumns(selectBody, sources); if (!selectStar && columns.length === 0) return { editable: false, reason: "computed-columns" }; + const analysis: EditableQueryInfo = { + catalog: source.catalog, + catalogQuoted: source.catalogQuoted, + schema: source.schema, + schemaQuoted: source.schemaQuoted, + tableName: source.tableName, + tableNameQuoted: source.tableNameQuoted, + tableAlias: source.alias, + selectStar, + columns, + }; + if (sources.length > 1) { + analysis.sources = sources; + analysis.multiSource = true; + analysis.allowInsertDelete = false; + } return { editable: true, - analysis: { - schema: source.schema, - schemaQuoted: source.schemaQuoted, - tableName: source.tableName, - tableNameQuoted: source.tableNameQuoted, - tableAlias: source.alias, - selectStar, - columns, - }, + analysis, }; } export function queryEditabilityMessageKey(reason: QueryEditabilityReason): string { - return { - "not-select": "grid.queryEditUnsupportedNotSelect", - cte: "grid.queryEditUnsupportedCte", - "set-operation": "grid.queryEditUnsupportedSetOperation", - aggregation: "grid.queryEditUnsupportedAggregation", - "external-source": "grid.queryEditUnsupportedExternalSource", - "complex-source": "grid.queryEditUnsupportedComplexSource", - "computed-columns": "grid.queryEditUnsupportedComputedColumns", - "no-table": "grid.queryEditUnsupportedNoTable", - "no-primary-key": "grid.queryEditUnsupportedNoPrimaryKey", - "primary-key-not-returned": "grid.queryEditUnsupportedPrimaryKeyNotReturned", - "aliased-columns": "grid.queryEditUnsupportedAliasedColumns", - "metadata-unavailable": "grid.queryEditUnsupportedMetadataUnavailable", - }[reason]; + return `grid.queryEditUnsupported.${reason}`; } -function parseSelectColumns(body: string): EditableQueryColumn[] { +function parseSelectColumns(body: string, sources?: EditableQuerySource[]): EditableQueryColumn[] { const cols: EditableQueryColumn[] = []; let depth = 0; let current = ""; @@ -122,7 +144,7 @@ function parseSelectColumns(body: string): EditableQueryColumn[] { else if (ch === "(") depth++; else if (ch === ")") depth--; else if (ch === "," && depth === 0) { - const col = parseSelectColumn(current.trim()); + const col = parseSelectColumn(current.trim(), sources); if (!col) return []; cols.push(col); current = ""; @@ -131,26 +153,55 @@ function parseSelectColumns(body: string): EditableQueryColumn[] { current += ch; } if (current.trim()) { - const col = parseSelectColumn(current.trim()); + const col = parseSelectColumn(current.trim(), sources); if (!col) return []; cols.push(col); } return cols; } -function parseSelectColumn(col: string): EditableQueryColumn | null { +function parseSelectColumn(col: string, sources?: EditableQuerySource[]): EditableQueryColumn | null { + const star = parseStarSelectColumn(col, sources); + if (star) return star; const source = parseQualifiedIdentifier(col); if (!source) return parseComputedSelectColumn(col); const alias = parseColumnAlias(source.rest); if (alias === null) return parseComputedSelectColumn(col); + const sourceName = source.parts[source.parts.length - 1]; + const qualifier = source.parts.length >= 2 ? source.parts[source.parts.length - 2] : undefined; + const sourceKey = qualifier ? sourceKeyForQualifier(sources, qualifier) : undefined; return { - sourceName: source.parts[source.parts.length - 1], + sourceName, sourceNameQuoted: source.quoted[source.quoted.length - 1], - resultName: alias ?? source.parts[source.parts.length - 1], + ...(qualifier ? { sourceQualifier: qualifier } : {}), + ...(sourceKey ? { sourceKey } : {}), + resultName: alias ?? sourceName, expression: col.slice(0, source.end).trim(), }; } +function parseStarSelectColumn(col: string, sources?: EditableQuerySource[]): EditableQueryColumn | null { + if (col === "*") { + return { + star: true, + resultName: "*", + expression: col, + }; + } + const starMatch = col.match(/^((?:[A-Za-z_][\w$]*|"[^"]+"|`[^`]+`|\[[^\]]+\]))\s*\.\s*\*$/); + if (!starMatch) return null; + const qualifier = readIdentifier(starMatch[1]!, 0); + if (!qualifier) return null; + const sourceKey = sourceKeyForQualifier(sources, qualifier.value); + return { + star: true, + sourceQualifier: qualifier.value, + ...(sourceKey ? { sourceKey } : {}), + resultName: "*", + expression: col, + }; +} + function parseComputedSelectColumn(col: string): EditableQueryColumn | null { const alias = parseExpressionAlias(col); if (!alias) return null; @@ -188,23 +239,77 @@ function isSelectStar(body: string, alias: string | undefined): boolean { return new RegExp(`^${escapeRegExp(alias)}\\s*\\.\\s*\\*$`, "i").test(trimmed); } -function parseFromSource(body: string): { schema?: string; schemaQuoted?: boolean; tableName: string; tableNameQuoted?: boolean; alias?: string } | null { - if (!body || /[,()]/.test(body) || /\bJOIN\b/i.test(body)) return null; - const ident = parseQualifiedIdentifier(body); - if (!ident || ident.parts.length < 1 || ident.parts.length > 2) return null; - const tail = ident.rest.trim(); - let alias: string | undefined; - if (tail) { - const aliasText = tail.replace(/^AS\s+/i, "").trim(); - const aliasIdent = readIdentifier(aliasText, 0); - if (!aliasIdent || aliasIdent.end !== aliasText.length) return null; - alias = aliasIdent.value; +function parseFromSources(body: string): EditableQuerySource[] { + if (!body || /[()]/.test(body)) return []; + const sources: EditableQuerySource[] = []; + let pos = 0; + const first = parseTableSourceAt(body, pos, sources.length); + if (!first) return []; + sources.push(first.source); + pos = first.end; + + while (pos < body.length) { + pos = skipWhitespace(body, pos); + if (pos >= body.length) break; + if (body[pos] === ",") { + const next = parseTableSourceAt(body, pos + 1, sources.length); + if (!next) return []; + sources.push(next.source); + pos = next.end; + continue; + } + const joinIndex = findTopLevelKeyword(body, "JOIN", pos); + if (joinIndex < 0) break; + const next = parseTableSourceAt(body, joinIndex + "JOIN".length, sources.length); + if (!next) return []; + sources.push(next.source); + pos = next.end; } - const tableName = ident.parts[ident.parts.length - 1]; + + return sources; +} + +function parseTableSourceAt(text: string, start: number, index: number): { source: EditableQuerySource; end: number } | null { + const pos = skipWhitespace(text, start); + if (text[pos] === "'" || text[pos] === "(") return null; + const ident = parseQualifiedIdentifier(text.slice(pos)); + if (!ident || ident.parts.length < 1 || ident.parts.length > 3) return null; + if (ident.rest.trimStart().startsWith("(")) return null; + + let end = pos + ident.end; + let alias: string | undefined; + let tailPos = skipWhitespace(text, end); + if (startsWithKeyword(text, tailPos, "AS")) { + const aliasIdent = readIdentifier(text, tailPos + 2); + if (!aliasIdent) return null; + alias = aliasIdent.value; + end = aliasIdent.end; + tailPos = skipWhitespace(text, end); + } else if (!tableSourceTerminatorAt(text, tailPos)) { + const aliasIdent = readIdentifier(text, tailPos); + if (!aliasIdent) return null; + alias = aliasIdent.value; + end = aliasIdent.end; + } + + const tableName = ident.parts[ident.parts.length - 1]!; const tableNameQuoted = ident.quoted[ident.quoted.length - 1]; - const schema = ident.parts.length === 2 ? ident.parts[0] : undefined; - const schemaQuoted = ident.parts.length === 2 ? ident.quoted[0] : false; - return { schema, schemaQuoted, tableName, tableNameQuoted, alias }; + const schema = ident.parts.length >= 2 ? ident.parts[ident.parts.length - 2] : undefined; + const schemaQuoted = ident.parts.length >= 2 ? ident.quoted[ident.quoted.length - 2] : false; + const catalog = ident.parts.length === 3 ? ident.parts[0] : undefined; + const catalogQuoted = ident.parts.length === 3 ? ident.quoted[0] : false; + return { + source: { + key: `${alias ?? tableName}:${index}`, + ...(catalog ? { catalog, catalogQuoted } : {}), + schema, + schemaQuoted, + tableName, + tableNameQuoted, + alias, + }, + end, + }; } function isExternalFromSource(body: string): boolean { @@ -212,6 +317,27 @@ function isExternalFromSource(body: string): boolean { return /^'(?:''|[^'])*'(?:\s+(?:AS\s+)?[A-Za-z_][\w$]*)?$/i.test(trimmed) || /^[A-Za-z_][\w$]*\s*\(/.test(trimmed); } +function tableSourceTerminatorAt(text: string, pos: number): boolean { + if (pos >= text.length) return true; + if (text[pos] === ",") return true; + return ["ON", "USING", "JOIN", "LEFT", "RIGHT", "INNER", "FULL", "CROSS", "OUTER", "NATURAL"].some((keyword) => startsWithKeyword(text, pos, keyword)); +} + +function startsWithKeyword(text: string, pos: number, keyword: string): boolean { + const start = skipWhitespace(text, pos); + if (text.slice(start, start + keyword.length).toUpperCase() !== keyword) return false; + const before = start === 0 ? "" : text[start - 1]; + const after = text[start + keyword.length] ?? ""; + return !isIdentifierChar(before) && !isIdentifierChar(after); +} + +function sourceKeyForQualifier(sources: EditableQuerySource[] | undefined, qualifier: string): string | undefined { + if (!sources?.length) return undefined; + const normalizedQualifier = qualifier.toLowerCase(); + const matches = sources.filter((source) => (source.alias ?? source.tableName).toLowerCase() === normalizedQualifier || source.tableName.toLowerCase() === normalizedQualifier); + return matches.length === 1 ? matches[0]!.key : undefined; +} + function parseQualifiedIdentifier(text: string): { parts: string[]; quoted: boolean[]; end: number; rest: string; done: boolean } | null { const parts: string[] = []; const quoted: boolean[] = []; @@ -311,27 +437,52 @@ function escapeRegExp(value: string): string { * Check if all primary key columns are present in the result set columns. * Comparison is case-insensitive. */ -export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[], analysis?: EditableQueryInfo): boolean { +export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[], analysis?: EditableQueryInfo, sourceKey?: string): boolean { if (analysis && !analysis.selectStar) { - const sourceColumns = new Set(analysis.columns.flatMap((column) => (column.sourceName ? [column.sourceName.toLowerCase()] : []))); + const sourceColumns = new Set( + analysis.columns.flatMap((column) => { + if (!column.sourceName) return []; + if (sourceKey && column.sourceKey !== sourceKey) return []; + return [column.sourceName.toLowerCase()]; + }), + ); return primaryKeys.every((pk) => sourceColumns.has(pk.toLowerCase())); } const colSet = new Set(resultColumns.map((c) => c.toLowerCase())); return primaryKeys.every((pk) => colSet.has(pk.toLowerCase())); } -export function allEditableColumnsWriteable(analysis: EditableQueryInfo, resultColumns: string[]): boolean { - if (analysis.selectStar) return true; - const sourceByResult = new Map(analysis.columns.map((column) => [column.resultName.toLowerCase(), column])); - return resultColumns.every((resultColumn) => { - const source = sourceByResult.get(resultColumn.toLowerCase()); - return !!source; - }); +function matchColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[]): EditableQueryColumn[] | undefined { + const matches: EditableQueryColumn[] = []; + let searchFrom = 0; + for (const resultColumn of resultColumns) { + const normalized = resultColumn.toLowerCase(); + let matchIndex = -1; + for (let index = searchFrom; index < analysis.columns.length; index++) { + if (analysis.columns[index]!.resultName.toLowerCase() === normalized) { + matchIndex = index; + break; + } + } + if (matchIndex < 0) return undefined; + matches.push(analysis.columns[matchIndex]!); + searchFrom = matchIndex + 1; + } + return matches; } -export function sourceColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[]): Array | undefined { +export function allEditableColumnsWriteable(analysis: EditableQueryInfo, resultColumns: string[], sourceKey?: string): boolean { + if (analysis.selectStar) return true; + const matchedColumns = matchColumnsForResult(analysis, resultColumns); + return !!matchedColumns && matchedColumns.every((source) => !sourceKey || !source.sourceName || source.sourceKey === sourceKey); +} + +export function sourceColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[], sourceKey?: string): Array | undefined { if (analysis.selectStar) return undefined; - const sourceByResult = new Map(analysis.columns.map((column) => [column.resultName.toLowerCase(), column])); - const sourceColumns = resultColumns.map((resultColumn) => sourceByResult.get(resultColumn.toLowerCase())?.sourceName); - return resultColumns.every((resultColumn) => sourceByResult.has(resultColumn.toLowerCase())) ? sourceColumns : undefined; + const matchedColumns = matchColumnsForResult(analysis, resultColumns); + if (!matchedColumns) return undefined; + return matchedColumns.map((column) => { + if (sourceKey && column.sourceKey !== sourceKey) return undefined; + return column.sourceName; + }); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 174a58c396..1abcce9c34 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -6,7 +6,7 @@ import type { DatabaseType, QueryResult, QueryTab, TableInfoTab, TableStructureE import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/diagram/explainPlan"; -import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sql/sqlAnalysis"; +import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence"; import { evaluateMongoAggregateSafety, @@ -116,6 +116,9 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable 1) return undefined; const qualifier = analysis.schema || database?.trim(); return qualifier ? `${qualifier}.${analysis.tableName}` : analysis.tableName; } @@ -179,6 +182,11 @@ function normalizeOracleLikeQueryAnalysis(dbType: string, analysis: EditableQuer ...analysis, schema, tableName, + sources: analysis.sources?.map((source) => ({ + ...source, + schema: normalizeOracleLikeMetadataIdentifier(dbType, source.schema, source.schemaQuoted), + tableName: normalizeOracleLikeMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!, + })), columns: analysis.columns.map((column) => ({ ...column, sourceName: normalizeOracleLikeMetadataIdentifier(dbType, column.sourceName, column.sourceNameQuoted), @@ -186,6 +194,75 @@ function normalizeOracleLikeQueryAnalysis(dbType: string, analysis: EditableQuer }; } +function editableQuerySources(analysis: EditableQueryInfo): EditableQuerySource[] { + return analysis.sources?.length + ? analysis.sources + : [ + { + key: `${analysis.tableAlias ?? analysis.tableName}:0`, + catalog: analysis.catalog, + catalogQuoted: analysis.catalogQuoted, + schema: analysis.schema, + schemaQuoted: analysis.schemaQuoted, + tableName: analysis.tableName, + tableNameQuoted: analysis.tableNameQuoted, + alias: analysis.tableAlias, + }, + ]; +} + +function cloneAnalysisForSource(analysis: EditableQueryInfo, source: EditableQuerySource): EditableQueryInfo { + return { + ...analysis, + catalog: source.catalog, + catalogQuoted: source.catalogQuoted, + schema: source.schema, + schemaQuoted: source.schemaQuoted, + tableName: source.tableName, + tableNameQuoted: source.tableNameQuoted, + tableAlias: source.alias, + editableSourceKey: source.key, + allowInsertDelete: analysis.sources?.length ? false : analysis.allowInsertDelete, + }; +} + +function sourceMatchesColumn(columnName: string, tableColumns: readonly { name: string }[]): boolean { + const normalizedColumn = columnName.toLowerCase(); + return tableColumns.some((column) => column.name.toLowerCase() === normalizedColumn); +} + +function bindUnqualifiedColumnsForSource(analysis: EditableQueryInfo, source: EditableQuerySource, tableColumns: readonly { name: string }[], allSourceColumns: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }> = [{ source, columns: tableColumns }]): EditableQueryInfo { + return { + ...analysis, + columns: analysis.columns.map((column) => { + if (!column.sourceName || column.sourceKey || column.sourceQualifier) return column; + if (!sourceMatchesColumn(column.sourceName, tableColumns)) return column; + const matchingSources = allSourceColumns.filter((entry) => sourceMatchesColumn(column.sourceName!, entry.columns)); + if (matchingSources.length !== 1 || matchingSources[0]?.source.key !== source.key) return column; + return { ...column, sourceKey: source.key }; + }), + }; +} + +function expandStarProjectionColumnsForSource(analysis: EditableQueryInfo, source: EditableQuerySource, tableColumns: readonly { name: string }[]): EditableQueryInfo { + if (analysis.selectStar || !analysis.columns.some((column) => column.star)) return analysis; + return { + ...analysis, + columns: analysis.columns.flatMap((column) => { + if (!column.star) return [column]; + if (column.sourceKey && column.sourceKey !== source.key) return [column]; + return tableColumns.map((tableColumn) => ({ + sourceName: tableColumn.name, + sourceNameQuoted: false, + ...(column.sourceQualifier ? { sourceQualifier: column.sourceQualifier } : {}), + sourceKey: source.key, + resultName: tableColumn.name, + expression: column.sourceQualifier ? `${column.sourceQualifier}.${tableColumn.name}` : tableColumn.name, + })); + }), + }; +} + let saveTabsQueue = Promise.resolve(); function saveTabs(tabs: QueryTab[], activeTabId: string | null): Promise { @@ -1319,7 +1396,7 @@ export const useQueryStore = defineStore("query", () => { objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined, objectSource: original.objectSource ? { ...original.objectSource } : undefined, tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined, - queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined, + queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined, querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined, queryEditabilityReason: original.queryEditabilityReason, resultEvicted: undefined, @@ -1808,88 +1885,168 @@ export const useQueryStore = defineStore("query", () => { }; } - // Resolve schema per database type const connStore = useConnectionStore(); const conn = connStore.getConfig(tab.connectionId); const dbType = conn?.db_type || ""; - let schema = analysis.schema || tab.schema; - if (!schema) { - if (dbType === "postgres" || dbType === "kwdb") schema = "public"; - else schema = ""; - } - const resolvedSchema = metadataSchemaForConnection(conn, tab.database, schema || undefined); - const metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, resolvedSchema || undefined, analysis.schema ? analysis.schemaQuoted : false) || ""; - const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, analysis.tableName, analysis.tableNameQuoted)!; - const metadataAnalysis = normalizeOracleLikeQueryAnalysis(dbType, analysis, metadataSchema || undefined, metadataTableName); - + const sources = editableQuerySources(analysis); + type LoadedEditableSource = { + source: EditableQuerySource; + analysis: EditableQueryInfo; + tableMeta: NonNullable; + }; + const loadedSources: LoadedEditableSource[] = []; try { - console.info("[DBX][executeTabSql:metadata:table:start]", { - traceId, - schema: metadataSchema, - table: metadataTableName, - elapsed: elapsed?.(), - }); - const loadedMetadata = await loadTableMetadata({ - connectionId: tab.connectionId, - database: tab.database, - schema: metadataSchema, - tableName: metadataTableName, - tableType: tab.tableMeta?.tableType, - databaseType: dbType, - driverProfile: conn?.driver_profile || conn?.db_type, - traceLogger: (event) => console.debug("[DBX][executeTabSql:metadata:table-trace]", { sourceTraceId: traceId, ...event }), - }); - const columns = loadedMetadata.metadata.columns; - const primaryKeys = loadedMetadata.metadata.primaryKeys; - console.info("[DBX][executeTabSql:metadata:table:done]", { - traceId, - columnCount: columns.length, - primaryKeyCount: primaryKeys.length, - cacheStatus: loadedMetadata.cacheStatus, - ageMs: Math.round(loadedMetadata.ageMs), - elapsed: elapsed?.(), - }); - const tableType = loadedMetadata.metadata.tableType; - const tableMeta = { - schema: metadataSchema || undefined, - tableName: metadataTableName, - tableType, - columns, - primaryKeys, + for (const source of sources) { + let schema = source.schema || tab.schema; + if (!schema) { + if (dbType === "postgres" || dbType === "kwdb") schema = "public"; + else schema = ""; + } + const resolvedSchema = metadataSchemaForConnection(conn, tab.database, schema || undefined); + const metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || ""; + const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!; + const metadataCatalog = normalizeOracleLikeMetadataIdentifier(dbType, source.catalog, source.catalogQuoted); + const metadataSource: EditableQuerySource = { + ...source, + catalog: metadataCatalog, + schema: metadataSchema || undefined, + tableName: metadataTableName, + }; + console.info("[DBX][executeTabSql:metadata:table:start]", { + traceId, + schema: metadataSchema, + table: metadataTableName, + alias: source.alias, + elapsed: elapsed?.(), + }); + const loadedMetadata = await loadTableMetadata({ + connectionId: tab.connectionId, + database: tab.database, + schema: metadataSchema, + tableName: metadataTableName, + tableType: tab.tableMeta?.tableType, + databaseType: dbType, + driverProfile: conn?.driver_profile || conn?.db_type, + catalog: metadataCatalog, + traceLogger: (event) => console.debug("[DBX][executeTabSql:metadata:table-trace]", { sourceTraceId: traceId, ...event }), + }); + const columns = loadedMetadata.metadata.columns; + const primaryKeys = loadedMetadata.metadata.primaryKeys; + console.info("[DBX][executeTabSql:metadata:table:done]", { + traceId, + columnCount: columns.length, + primaryKeyCount: primaryKeys.length, + cacheStatus: loadedMetadata.cacheStatus, + ageMs: Math.round(loadedMetadata.ageMs), + elapsed: elapsed?.(), + }); + const tableType = loadedMetadata.metadata.tableType; + const tableMeta = { + catalog: metadataCatalog, + schema: metadataSchema || undefined, + tableName: metadataTableName, + tableType, + columns, + primaryKeys, + }; + loadedSources.push({ + source: metadataSource, + analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), + tableMeta, + }); + } + + const allSourceColumns = loadedSources.map((source) => ({ source: source.source, columns: source.tableMeta.columns })); + // Match DBeaver's safety model: a joined result is writable only when one + // source table has a complete row identifier and at least one writable column. + const candidates = loadedSources + .map((loaded) => { + const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns); + const primaryKeys = loaded.tableMeta.primaryKeys; + const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result!.columns, loaded.source.key); + const primaryKeysPresent = allPrimaryKeysPresent(primaryKeys, tab.result!.columns, metadataAnalysis, loaded.source.key); + const keylessAllowed = sources.length === 1 && canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys); + const primaryKeySet = new Set(primaryKeys.map((key) => key.toLowerCase())); + const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column.toLowerCase())).length; + return { + ...loaded, + analysis: metadataAnalysis, + sourceColumns, + primaryKeysPresent, + keylessAllowed, + editableSourceColumnCount, + }; + }) + .filter((loaded) => (loaded.primaryKeysPresent || loaded.keylessAllowed) && !!loaded.sourceColumns && loaded.editableSourceColumnCount > 0); + + if (loadedSources.length === 1) { + const loaded = loadedSources[0]!; + const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns); + const primaryKeys = loaded.tableMeta.primaryKeys; + if (primaryKeys.length === 0 && !canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys)) { + return { + queryAnalysis: undefined, + querySourceColumns: undefined, + queryEditabilityReason: "no-primary-key", + tableMeta: loaded.tableMeta, + }; + } + + if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key)) { + return { + queryAnalysis: undefined, + querySourceColumns: undefined, + queryEditabilityReason: "primary-key-not-returned", + tableMeta: loaded.tableMeta, + }; + } + + if (!allEditableColumnsWriteable(metadataAnalysis, tab.result.columns)) { + return { + queryAnalysis: undefined, + querySourceColumns: undefined, + queryEditabilityReason: "aliased-columns", + tableMeta: loaded.tableMeta, + }; + } + + return { + queryAnalysis: metadataAnalysis, + querySourceColumns: sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key), + queryEditabilityReason: undefined, + tableMeta: loaded.tableMeta, + }; + } + + if (candidates.length === 0) { + return { + queryAnalysis: undefined, + querySourceColumns: undefined, + queryEditabilityReason: loadedSources.some((loaded) => loaded.tableMeta.primaryKeys.length > 0) ? "primary-key-not-returned" : "no-primary-key", + tableMeta: undefined, + }; + } + + if (candidates.length > 1) { + return { + queryAnalysis: undefined, + querySourceColumns: undefined, + queryEditabilityReason: "complex-source", + tableMeta: undefined, + }; + } + + const target = candidates[0]!; + const queryAnalysis = { + ...target.analysis, + allowInsertDelete: false, + multiSource: true, }; - - if (primaryKeys.length === 0 && !canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys)) { - return { - queryAnalysis: undefined, - querySourceColumns: undefined, - queryEditabilityReason: "no-primary-key", - tableMeta, - }; - } - - if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis)) { - return { - queryAnalysis: undefined, - querySourceColumns: undefined, - queryEditabilityReason: "primary-key-not-returned", - tableMeta, - }; - } - - if (!allEditableColumnsWriteable(metadataAnalysis, tab.result.columns)) { - return { - queryAnalysis: undefined, - querySourceColumns: undefined, - queryEditabilityReason: "aliased-columns", - tableMeta, - }; - } - return { - queryAnalysis: metadataAnalysis, - querySourceColumns: sourceColumnsForResult(metadataAnalysis, tab.result.columns), + queryAnalysis, + querySourceColumns: target.sourceColumns, queryEditabilityReason: undefined, - tableMeta, + tableMeta: target.tableMeta, }; } catch (err) { console.error("[DBX] ERROR fetching columns for query metadata:", err); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 069ee3b84d..b4599382a1 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -732,15 +732,33 @@ export interface QueryTab { tableMetaUpdatedAt?: number; tableInfoTab?: TableInfoTab; queryAnalysis?: { + catalog?: string; + catalogQuoted?: boolean; schema?: string; schemaQuoted?: boolean; tableName: string; tableNameQuoted?: boolean; tableAlias?: string; selectStar: boolean; + editableSourceKey?: string; + multiSource?: boolean; + allowInsertDelete?: boolean; + sources?: { + key: string; + catalog?: string; + catalogQuoted?: boolean; + schema?: string; + schemaQuoted?: boolean; + tableName: string; + tableNameQuoted?: boolean; + alias?: string; + }[]; columns: { sourceName?: string; sourceNameQuoted?: boolean; + sourceQualifier?: string; + sourceKey?: string; + star?: boolean; resultName: string; expression: string; }[]; diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 820bc2c157..6a152df9a6 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditableQueryInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub catalog_quoted: bool, #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option, pub schema_quoted: bool, @@ -12,6 +16,14 @@ pub struct EditableQueryInfo { pub table_alias: Option, pub select_star: bool, pub columns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub editable_source_key: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub multi_source: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_insert_delete: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -20,10 +32,33 @@ pub struct EditableQueryColumn { #[serde(skip_serializing_if = "Option::is_none")] pub source_name: Option, pub source_name_quoted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_qualifier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_key: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub star: bool, pub result_name: String, pub expression: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditableQuerySource { + pub key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub catalog_quoted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub schema_quoted: bool, + pub table_name: String, + pub table_name_quoted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum QueryEditabilityReason { @@ -53,6 +88,9 @@ pub struct QueryEditability { #[derive(Debug, Clone, PartialEq, Eq)] struct FromSource { + key: String, + catalog: Option, + catalog_quoted: bool, schema: Option, schema_quoted: bool, table_name: String, @@ -60,6 +98,10 @@ struct FromSource { alias: Option, } +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, PartialEq, Eq)] struct QualifiedIdentifier { parts: Vec, @@ -123,36 +165,50 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability { if is_external_from_source(from_body) { return not_editable(QueryEditabilityReason::ExternalSource); } - let Some(source) = parse_from_source(from_body) else { + let sources = parse_from_sources(from_body); + if sources.is_empty() { return not_editable(QueryEditabilityReason::ComplexSource); - }; + } + let source = sources[0].clone(); - let select_star = is_select_star(select_body, source.alias.as_deref()); - let columns = if select_star { Vec::new() } else { parse_select_columns(select_body) }; + let select_star = sources.len() == 1 && is_select_star(select_body, source.alias.as_deref()); + if sources.len() > 1 && select_body_contains_star_projection(select_body) { + return not_editable(QueryEditabilityReason::ComplexSource); + } + let columns = if select_star { Vec::new() } else { parse_select_columns(select_body, &sources) }; if !select_star && columns.is_empty() { return not_editable(QueryEditabilityReason::ComputedColumns); } - QueryEditability { - editable: true, - analysis: Some(EditableQueryInfo { - schema: source.schema, - schema_quoted: source.schema_quoted, - table_name: source.table_name, - table_name_quoted: source.table_name_quoted, - table_alias: source.alias, - select_star, - columns, - }), - reason: None, + let mut analysis = EditableQueryInfo { + catalog: source.catalog, + catalog_quoted: source.catalog_quoted, + schema: source.schema, + schema_quoted: source.schema_quoted, + table_name: source.table_name, + table_name_quoted: source.table_name_quoted, + table_alias: source.alias, + select_star, + columns, + sources: None, + editable_source_key: None, + multi_source: false, + allow_insert_delete: None, + }; + if sources.len() > 1 { + analysis.sources = Some(sources.into_iter().map(EditableQuerySource::from).collect()); + analysis.multi_source = true; + analysis.allow_insert_delete = Some(false); } + + QueryEditability { editable: true, analysis: Some(analysis), reason: None } } fn not_editable(reason: QueryEditabilityReason) -> QueryEditability { QueryEditability { editable: false, analysis: None, reason: Some(reason) } } -fn parse_select_columns(body: &str) -> Vec { +fn parse_select_columns(body: &str, sources: &[FromSource]) -> Vec { let mut columns = Vec::new(); let mut depth = 0i32; let mut current = String::new(); @@ -173,7 +229,7 @@ fn parse_select_columns(body: &str) -> Vec { '(' => depth += 1, ')' => depth -= 1, ',' if depth == 0 => { - let Some(column) = parse_select_column(current.trim()) else { + let Some(column) = parse_select_column(current.trim(), sources) else { return Vec::new(); }; columns.push(column); @@ -186,7 +242,7 @@ fn parse_select_columns(body: &str) -> Vec { } if !current.trim().is_empty() { - let Some(column) = parse_select_column(current.trim()) else { + let Some(column) = parse_select_column(current.trim(), sources) else { return Vec::new(); }; columns.push(column); @@ -195,7 +251,10 @@ fn parse_select_columns(body: &str) -> Vec { columns } -fn parse_select_column(column: &str) -> Option { +fn parse_select_column(column: &str, sources: &[FromSource]) -> Option { + if let Some(star) = parse_star_select_column(column, sources) { + return Some(star); + } let Some(source) = parse_qualified_identifier(column) else { return parse_computed_select_column(column); }; @@ -203,21 +262,67 @@ fn parse_select_column(column: &str) -> Option { let Some(alias) = parse_column_alias(rest) else { return parse_computed_select_column(column); }; - let source = source.parts.last()?; - let source_name = source.value.clone(); + let source_name_part = source.parts.last()?; + let source_name = source_name_part.value.clone(); + let qualifier = if source.parts.len() >= 2 { + source.parts.get(source.parts.len() - 2).map(|part| part.value.clone()) + } else { + None + }; + let source_key = qualifier.as_deref().and_then(|qualifier| source_key_for_qualifier(sources, qualifier)); Some(EditableQueryColumn { source_name: Some(source_name.clone()), - source_name_quoted: source.quoted, + source_name_quoted: source_name_part.quoted, + source_qualifier: qualifier, + source_key, + star: false, result_name: alias.unwrap_or(source_name), expression: column[..source.end].trim().to_string(), }) } +fn parse_star_select_column(column: &str, sources: &[FromSource]) -> Option { + let trimmed = column.trim(); + if trimmed == "*" { + return Some(EditableQueryColumn { + source_name: None, + source_name_quoted: false, + source_qualifier: None, + source_key: None, + star: true, + result_name: "*".to_string(), + expression: trimmed.to_string(), + }); + } + let qualifier = read_identifier(trimmed, 0)?; + let mut pos = skip_whitespace(trimmed, qualifier.end); + if !trimmed[pos..].starts_with('.') { + return None; + } + pos = skip_whitespace(trimmed, pos + 1); + if trimmed[pos..].trim() != "*" { + return None; + } + let source_key = source_key_for_qualifier(sources, &qualifier.value); + Some(EditableQueryColumn { + source_name: None, + source_name_quoted: false, + source_qualifier: Some(qualifier.value), + source_key, + star: true, + result_name: "*".to_string(), + expression: trimmed.to_string(), + }) +} + fn parse_computed_select_column(column: &str) -> Option { let alias = parse_expression_alias(column)?; Some(EditableQueryColumn { source_name: None, source_name_quoted: false, + source_qualifier: None, + source_key: None, + star: false, result_name: alias.result_name, expression: alias.expression, }) @@ -298,40 +403,87 @@ fn is_select_star(body: &str, alias: Option<&str>) -> bool { prefix.trim().eq_ignore_ascii_case(alias) && suffix.trim() == "*" } -fn parse_from_source(body: &str) -> Option { - if body.is_empty() - || body.contains(',') - || body.contains('(') - || body.contains(')') - || contains_keyword(body, "JOIN") - { +fn parse_from_sources(body: &str) -> Vec { + if body.is_empty() || body.contains('(') || body.contains(')') { + return Vec::new(); + } + + let mut sources = Vec::new(); + let Some(first) = parse_table_source_at(body, 0, sources.len()) else { + return Vec::new(); + }; + sources.push(first.0); + let mut pos = first.1; + + while pos < body.len() { + pos = skip_whitespace(body, pos); + if pos >= body.len() { + break; + } + if body[pos..].starts_with(',') { + let Some(next) = parse_table_source_at(body, pos + 1, sources.len()) else { + return Vec::new(); + }; + sources.push(next.0); + pos = next.1; + continue; + } + let Some(join_index) = find_top_level_keyword(body, "JOIN", pos) else { + break; + }; + let Some(next) = parse_table_source_at(body, join_index + "JOIN".len(), sources.len()) else { + return Vec::new(); + }; + sources.push(next.0); + pos = next.1; + } + + sources +} + +fn parse_table_source_at(text: &str, start: usize, index: usize) -> Option<(FromSource, usize)> { + let pos = skip_whitespace(text, start); + if text[pos..].starts_with('\'') || text[pos..].starts_with('(') { return None; } - let ident = parse_qualified_identifier(body)?; - if ident.parts.is_empty() || ident.parts.len() > 2 { + let ident = parse_qualified_identifier(&text[pos..])?; + if ident.parts.is_empty() || ident.parts.len() > 3 { return None; } - let tail = body[ident.end..].trim(); - let alias = if tail.is_empty() { + let mut end = pos + ident.end; + let tail_pos = skip_whitespace(text, end); + let alias = if starts_with_keyword_at(text, tail_pos, "AS") { + let alias_ident = read_identifier(text, tail_pos + 2)?; + end = alias_ident.end; + Some(alias_ident.value) + } else if table_source_terminator_at(text, tail_pos) { None } else { - let alias_text = strip_leading_as(tail).unwrap_or(tail).trim(); - let alias_ident = read_identifier(alias_text, 0)?; - if alias_ident.end != alias_text.len() { - return None; - } + let alias_ident = read_identifier(text, tail_pos)?; + end = alias_ident.end; Some(alias_ident.value) }; + let table = ident.parts.last()?; let table_name = table.value.clone(); let table_name_quoted = table.quoted; - let (schema, schema_quoted) = if ident.parts.len() == 2 { - let schema = &ident.parts[0]; + let (schema, schema_quoted) = if ident.parts.len() >= 2 { + let schema = &ident.parts[ident.parts.len() - 2]; (Some(schema.value.clone()), schema.quoted) } else { (None, false) }; - Some(FromSource { schema, schema_quoted, table_name, table_name_quoted, alias }) + let (catalog, catalog_quoted) = if ident.parts.len() == 3 { + let catalog = &ident.parts[0]; + (Some(catalog.value.clone()), catalog.quoted) + } else { + (None, false) + }; + let key = format!("{}:{}", alias.as_deref().unwrap_or(&table_name), index); + Some(( + FromSource { key, catalog, catalog_quoted, schema, schema_quoted, table_name, table_name_quoted, alias }, + end, + )) } fn is_external_from_source(body: &str) -> bool { @@ -339,6 +491,105 @@ fn is_external_from_source(body: &str) -> bool { is_single_quoted_source_with_optional_alias(trimmed) || starts_with_table_function(trimmed) } +fn select_body_contains_star_projection(body: &str) -> bool { + split_top_level_commas(body).iter().any(|item| { + let trimmed = item.trim(); + if trimmed == "*" { + return true; + } + let Some((_, suffix)) = trimmed.split_once('.') else { + return false; + }; + suffix.trim() == "*" + }) +} + +fn split_top_level_commas(body: &str) -> Vec { + let mut items = Vec::new(); + let mut depth = 0i32; + let mut current = String::new(); + let mut quote: Option = None; + for ch in body.chars() { + if let Some(close) = quote { + current.push(ch); + if ch == close { + quote = None; + } + continue; + } + match ch { + '\'' | '"' | '`' => quote = Some(ch), + '[' => quote = Some(']'), + '(' => depth += 1, + ')' => depth = 0.max(depth - 1), + ',' if depth == 0 => { + items.push(current); + current = String::new(); + continue; + } + _ => {} + } + current.push(ch); + } + if !current.is_empty() { + items.push(current); + } + items +} + +fn table_source_terminator_at(text: &str, pos: usize) -> bool { + let pos = skip_whitespace(text, pos); + if pos >= text.len() || text[pos..].starts_with(',') { + return true; + } + ["ON", "USING", "JOIN", "LEFT", "RIGHT", "INNER", "FULL", "CROSS", "OUTER", "NATURAL"] + .iter() + .any(|keyword| starts_with_keyword_at(text, pos, keyword)) +} + +fn starts_with_keyword_at(text: &str, pos: usize, keyword: &str) -> bool { + let start = skip_whitespace(text, pos); + let Some(candidate) = text.get(start..start + keyword.len()) else { + return false; + }; + if !candidate.eq_ignore_ascii_case(keyword) { + return false; + } + let before = previous_char(text, start); + let after = text[start + keyword.len()..].chars().next(); + !before.is_some_and(is_identifier_char) && !after.is_some_and(is_identifier_char) +} + +fn source_key_for_qualifier(sources: &[FromSource], qualifier: &str) -> Option { + let matches = sources + .iter() + .filter(|source| { + source.alias.as_deref().is_some_and(|alias| alias.eq_ignore_ascii_case(qualifier)) + || source.table_name.eq_ignore_ascii_case(qualifier) + }) + .collect::>(); + if matches.len() == 1 { + Some(matches[0].key.clone()) + } else { + None + } +} + +impl From for EditableQuerySource { + fn from(source: FromSource) -> Self { + Self { + key: source.key, + catalog: source.catalog, + catalog_quoted: source.catalog_quoted, + schema: source.schema, + schema_quoted: source.schema_quoted, + table_name: source.table_name, + table_name_quoted: source.table_name_quoted, + alias: source.alias, + } + } +} + fn is_single_quoted_source_with_optional_alias(text: &str) -> bool { let mut chars = text.char_indices().peekable(); if chars.next().map(|(_, ch)| ch) != Some('\'') { @@ -528,10 +779,6 @@ fn starts_with_keyword(sql: &str, keyword: &str) -> bool { !trimmed[keyword.len()..].chars().next().is_some_and(is_identifier_char) } -fn contains_keyword(sql: &str, keyword: &str) -> bool { - find_top_level_keyword(sql, keyword, 0).is_some() -} - fn previous_char(text: &str, index: usize) -> Option { text[..index].chars().next_back() } @@ -549,34 +796,17 @@ mod tests { let result = analyze_editable_query_editability("select id, name from public.users where active = true order by id"); + assert!(result.editable); + assert_eq!(result.reason, None); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.schema.as_deref(), Some("public")); + assert_eq!(analysis.table_name, "users"); assert_eq!( - result, - QueryEditability { - editable: true, - analysis: Some(EditableQueryInfo { - schema: Some("public".to_string()), - schema_quoted: false, - table_name: "users".to_string(), - table_name_quoted: false, - table_alias: None, - select_star: false, - columns: vec![ - EditableQueryColumn { - source_name: Some("id".to_string()), - source_name_quoted: false, - result_name: "id".to_string(), - expression: "id".to_string(), - }, - EditableQueryColumn { - source_name: Some("name".to_string()), - source_name_quoted: false, - result_name: "name".to_string(), - expression: "name".to_string(), - }, - ], - }), - reason: None, - } + analysis.columns, + vec![ + column(Some("id"), false, None, None, "id", "id"), + column(Some("name"), false, None, None, "name", "name"), + ] ); } @@ -585,56 +815,76 @@ mod tests { let result = analyze_editable_query_editability(r#"SELECT u."id", u."full name" FROM "app schema"."user table" AS u"#); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.schema.as_deref(), Some("app schema")); + assert_eq!(analysis.table_name, "user table"); + assert_eq!(analysis.table_alias.as_deref(), Some("u")); assert_eq!( - result.analysis.unwrap(), - EditableQueryInfo { - schema: Some("app schema".to_string()), - schema_quoted: true, - table_name: "user table".to_string(), - table_name_quoted: true, - table_alias: Some("u".to_string()), - select_star: false, - columns: vec![ - EditableQueryColumn { - source_name: Some("id".to_string()), - source_name_quoted: true, - result_name: "id".to_string(), - expression: r#"u."id""#.to_string(), - }, - EditableQueryColumn { - source_name: Some("full name".to_string()), - source_name_quoted: true, - result_name: "full name".to_string(), - expression: r#"u."full name""#.to_string(), - }, - ], - } + analysis.columns, + vec![ + column(Some("id"), true, Some("u"), Some("u:0"), "id", r#"u."id""#), + column(Some("full name"), true, Some("u"), Some("u:0"), "full name", r#"u."full name""#), + ] ); } #[test] fn keeps_select_star_empty_columns() { + let analysis = analyze_editable_query("select * from users").unwrap(); + assert_eq!(analysis.table_name, "users"); + assert!(analysis.select_star); + assert!(analysis.columns.is_empty()); + } + + #[test] + fn maps_single_table_explicit_column_with_alias_star() { + let result = analyze_editable_query_editability( + "select t.create_date, t.* from tt_kd_material_container_sap t where t.order_no = 'KD2607071336' order by t.create_date desc", + ); + + assert!(result.editable); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.table_name, "tt_kd_material_container_sap"); + assert_eq!(analysis.table_alias.as_deref(), Some("t")); assert_eq!( - analyze_editable_query("select * from users").unwrap(), - EditableQueryInfo { - schema: None, - schema_quoted: false, - table_name: "users".to_string(), - table_name_quoted: false, - table_alias: None, - select_star: true, - columns: Vec::new(), - } + analysis.columns, + vec![ + column(Some("create_date"), false, Some("t"), Some("t:0"), "create_date", "t.create_date"), + star_column(Some("t"), Some("t:0"), "t.*"), + ] ); } #[test] - fn reports_joined_query_as_complex_source() { - let result = - analyze_editable_query_editability("select u.id, o.total from users u join orders o on o.user_id = u.id"); + fn maps_joined_query_source_columns() { + let result = analyze_editable_query_editability( + "select u.id as user_id, u.name, o.total from users u join orders o on o.user_id = u.id", + ); - assert!(!result.editable); - assert_eq!(result.reason, Some(QueryEditabilityReason::ComplexSource)); + assert!(result.editable); + let analysis = result.analysis.unwrap(); + assert!(analysis.multi_source); + assert_eq!(analysis.allow_insert_delete, Some(false)); + assert_eq!( + analysis + .sources + .unwrap() + .iter() + .map(|source| (&source.key, &source.table_name, source.alias.as_deref())) + .collect::>(), + vec![ + (&"u:0".to_string(), &"users".to_string(), Some("u")), + (&"o:1".to_string(), &"orders".to_string(), Some("o")), + ] + ); + assert_eq!( + analysis.columns, + vec![ + column(Some("id"), false, Some("u"), Some("u:0"), "user_id", "u.id"), + column(Some("name"), false, Some("u"), Some("u:0"), "name", "u.name"), + column(Some("total"), false, Some("o"), Some("o:1"), "total", "o.total"), + ] + ); } #[test] @@ -662,30 +912,10 @@ mod tests { assert_eq!( result.analysis.unwrap().columns, vec![ - EditableQueryColumn { - source_name: Some("iso3".to_string()), - source_name_quoted: false, - result_name: "iso3".to_string(), - expression: "iso3".to_string(), - }, - EditableQueryColumn { - source_name: Some("year".to_string()), - source_name_quoted: false, - result_name: "year".to_string(), - expression: "year".to_string(), - }, - EditableQueryColumn { - source_name: Some("country_name".to_string()), - source_name_quoted: false, - result_name: "country_name".to_string(), - expression: "country_name".to_string(), - }, - EditableQueryColumn { - source_name: None, - source_name_quoted: false, - result_name: "score".to_string(), - expression: "ihli / gdp_pc".to_string(), - }, + column(Some("iso3"), false, None, None, "iso3", "iso3"), + column(Some("year"), false, None, None, "year", "year"), + column(Some("country_name"), false, None, None, "country_name", "country_name"), + column(None, false, None, None, "score", "ihli / gdp_pc"), ] ); } @@ -696,4 +926,35 @@ mod tests { assert_eq!(json, serde_json::json!({ "editable": false, "reason": "set-operation" })); } + + fn column( + source_name: Option<&str>, + source_name_quoted: bool, + source_qualifier: Option<&str>, + source_key: Option<&str>, + result_name: &str, + expression: &str, + ) -> EditableQueryColumn { + EditableQueryColumn { + source_name: source_name.map(str::to_string), + source_name_quoted, + source_qualifier: source_qualifier.map(str::to_string), + source_key: source_key.map(str::to_string), + star: false, + result_name: result_name.to_string(), + expression: expression.to_string(), + } + } + + fn star_column(source_qualifier: Option<&str>, source_key: Option<&str>, expression: &str) -> EditableQueryColumn { + EditableQueryColumn { + source_name: None, + source_name_quoted: false, + source_qualifier: source_qualifier.map(str::to_string), + source_key: source_key.map(str::to_string), + star: true, + result_name: "*".to_string(), + expression: expression.to_string(), + } + } } diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 47b179f65e..09aec48bf7 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1229,6 +1229,293 @@ test("normalizes unquoted Oracle query identifiers before loading editable metad } }); +test("binds joined query edits to the single safe source table", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const columnRequests: Array<{ schema: string | null; table: string | null }> = []; + + connectionStore.addEphemeralConnection(conn("pg-join-1")); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/execute-multi") { + return new Response( + JSON.stringify([ + { + columns: ["user_id", "name", "total"], + rows: [[1, "Ada", 42]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/analyze-editability") { + const body = JSON.parse(String(init?.body ?? "{}")); + assert.equal(body.sql, "select u.id as user_id, u.name, o.total from users u join orders o on o.user_id = u.id"); + return new Response( + JSON.stringify({ + editable: true, + analysis: { + schema: undefined, + schemaQuoted: false, + tableName: "users", + tableNameQuoted: false, + tableAlias: "u", + selectStar: false, + multiSource: true, + allowInsertDelete: false, + sources: [ + { key: "u:0", tableName: "users", tableNameQuoted: false, schemaQuoted: false, alias: "u" }, + { key: "o:1", tableName: "orders", tableNameQuoted: false, schemaQuoted: false, alias: "o" }, + ], + columns: [ + { sourceName: "id", sourceNameQuoted: false, sourceQualifier: "u", sourceKey: "u:0", resultName: "user_id", expression: "u.id" }, + { sourceName: "name", sourceNameQuoted: false, sourceQualifier: "u", sourceKey: "u:0", resultName: "name", expression: "u.name" }, + { sourceName: "total", sourceNameQuoted: false, sourceQualifier: "o", sourceKey: "o:1", resultName: "total", expression: "o.total" }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.startsWith("/api/schema/columns?")) { + const params = new URL(url, "http://localhost").searchParams; + const table = params.get("table"); + columnRequests.push({ schema: params.get("schema"), table }); + const columns = + table === "users" + ? [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }, + { name: "name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + ] + : [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }, + { name: "user_id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null, comment: null }, + { name: "total", data_type: "numeric", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + ]; + return new Response(JSON.stringify(columns), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const sql = "select u.id as user_id, u.name, o.total from users u join orders o on o.user_id = u.id"; + const tabId = store.createTab("pg-join-1", "appdb", "Query 1", "query"); + await store.executeTabSql(tabId, sql); + + const tab = store.tabs.find((item) => item.id === tabId); + await waitFor(() => columnRequests.length === 2 && tab?.tableMeta?.tableName === "users"); + assert.deepEqual(columnRequests, [ + { schema: "public", table: "users" }, + { schema: "public", table: "orders" }, + ]); + assert.equal(tab?.queryEditabilityReason, undefined); + assert.equal(tab?.queryAnalysis?.multiSource, true); + assert.equal(tab?.queryAnalysis?.allowInsertDelete, false); + assert.equal(tab?.tableMeta?.tableName, "users"); + assert.deepEqual(tab?.querySourceColumns, ["id", "name", undefined]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("expands single-table alias star projections for editable query metadata", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const columnRequests: Array<{ schema: string | null; table: string | null }> = []; + + connectionStore.addEphemeralConnection(conn("pg-star-1")); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/execute-multi") { + return new Response( + JSON.stringify([ + { + columns: ["create_date", "id", "container_main_id"], + rows: [["2026-07-09 13:59:35", "20750975119640248", null]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/analyze-editability") { + const body = JSON.parse(String(init?.body ?? "{}")); + assert.equal(body.sql, "select t.create_date, t.* from tt_kd_material_container_sap t where t.order_no = 'KD2607071336' order by t.create_date desc"); + return new Response( + JSON.stringify({ + editable: true, + analysis: { + schema: undefined, + schemaQuoted: false, + tableName: "tt_kd_material_container_sap", + tableNameQuoted: false, + tableAlias: "t", + selectStar: false, + columns: [ + { sourceName: "create_date", sourceNameQuoted: false, sourceQualifier: "t", sourceKey: "t:0", resultName: "create_date", expression: "t.create_date" }, + { star: true, sourceQualifier: "t", sourceKey: "t:0", resultName: "*", expression: "t.*" }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.startsWith("/api/schema/columns?")) { + const params = new URL(url, "http://localhost").searchParams; + columnRequests.push({ schema: params.get("schema"), table: params.get("table") }); + return new Response( + JSON.stringify([ + { name: "create_date", data_type: "timestamp", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + { name: "id", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }, + { name: "container_main_id", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const sql = "select t.create_date, t.* from tt_kd_material_container_sap t where t.order_no = 'KD2607071336' order by t.create_date desc"; + const tabId = store.createTab("pg-star-1", "appdb", "Query 1", "query"); + await store.executeTabSql(tabId, sql); + + const tab = store.tabs.find((item) => item.id === tabId); + await waitFor(() => columnRequests.length === 1 && tab?.tableMeta?.tableName === "tt_kd_material_container_sap"); + assert.deepEqual(columnRequests, [{ schema: "public", table: "tt_kd_material_container_sap" }]); + assert.equal(tab?.queryEditabilityReason, undefined); + assert.equal(tab?.queryAnalysis?.selectStar, false); + assert.equal(tab?.tableMeta?.tableName, "tt_kd_material_container_sap"); + assert.deepEqual(tab?.querySourceColumns, ["create_date", "id", "container_main_id"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("keeps joined query read-only when multiple source tables are writable candidates", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const columnRequests: Array<{ schema: string | null; table: string | null }> = []; + + connectionStore.addEphemeralConnection(conn("pg-join-ambiguous")); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/execute-multi") { + return new Response( + JSON.stringify([ + { + columns: ["user_id", "name", "order_id", "total"], + rows: [[1, "Ada", 10, 42]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/analyze-editability") { + const body = JSON.parse(String(init?.body ?? "{}")); + assert.equal(body.sql, "select u.id as user_id, u.name, o.id as order_id, o.total from users u join orders o on o.user_id = u.id"); + return new Response( + JSON.stringify({ + editable: true, + analysis: { + schema: undefined, + schemaQuoted: false, + tableName: "users", + tableNameQuoted: false, + tableAlias: "u", + selectStar: false, + multiSource: true, + allowInsertDelete: false, + sources: [ + { key: "u:0", tableName: "users", tableNameQuoted: false, schemaQuoted: false, alias: "u" }, + { key: "o:1", tableName: "orders", tableNameQuoted: false, schemaQuoted: false, alias: "o" }, + ], + columns: [ + { sourceName: "id", sourceNameQuoted: false, sourceQualifier: "u", sourceKey: "u:0", resultName: "user_id", expression: "u.id" }, + { sourceName: "name", sourceNameQuoted: false, sourceQualifier: "u", sourceKey: "u:0", resultName: "name", expression: "u.name" }, + { sourceName: "id", sourceNameQuoted: false, sourceQualifier: "o", sourceKey: "o:1", resultName: "order_id", expression: "o.id" }, + { sourceName: "total", sourceNameQuoted: false, sourceQualifier: "o", sourceKey: "o:1", resultName: "total", expression: "o.total" }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.startsWith("/api/schema/columns?")) { + const params = new URL(url, "http://localhost").searchParams; + const table = params.get("table"); + columnRequests.push({ schema: params.get("schema"), table }); + const columns = + table === "users" + ? [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }, + { name: "name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + ] + : [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }, + { name: "total", data_type: "numeric", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + ]; + return new Response(JSON.stringify(columns), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const sql = "select u.id as user_id, u.name, o.id as order_id, o.total from users u join orders o on o.user_id = u.id"; + const tabId = store.createTab("pg-join-ambiguous", "appdb", "Query 1", "query"); + await store.executeTabSql(tabId, sql); + + const tab = store.tabs.find((item) => item.id === tabId); + await waitFor(() => columnRequests.length === 2 && tab?.queryEditabilityReason === "complex-source"); + assert.equal(tab?.queryAnalysis, undefined); + assert.equal(tab?.tableMeta, undefined); + assert.equal(tab?.querySourceColumns, undefined); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("uses dbo as SQL Server metadata schema when query omits schema", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/sqlAnalysis.test.ts b/packages/app-tests/sqlAnalysis.test.ts index 83878e5da5..81af508b98 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -6,58 +6,68 @@ test("recognizes a simple single-table SELECT as editable", () => { const result = analyzeEditableQueryEditability("select id, name from public.users where active = true order by id"); assert.equal(result.editable, true); - assert.deepEqual(result.analysis, { - schema: "public", - schemaQuoted: false, - tableName: "users", - tableNameQuoted: false, - tableAlias: undefined, - selectStar: false, - columns: [ - { sourceName: "id", sourceNameQuoted: false, resultName: "id", expression: "id" }, - { sourceName: "name", sourceNameQuoted: false, resultName: "name", expression: "name" }, - ], - }); + assert.equal(result.analysis.schema, "public"); + assert.equal(result.analysis.tableName, "users"); + assert.equal(result.analysis.selectStar, false); + assert.deepEqual(result.analysis.columns, [ + { sourceName: "id", sourceNameQuoted: false, resultName: "id", expression: "id" }, + { sourceName: "name", sourceNameQuoted: false, resultName: "name", expression: "name" }, + ]); }); test("recognizes quoted table names and table aliases", () => { const result = analyzeEditableQueryEditability('SELECT u."id", u."full name" FROM "app schema"."user table" AS u'); assert.equal(result.editable, true); - assert.deepEqual(result.analysis, { - schema: "app schema", - schemaQuoted: true, - tableName: "user table", - tableNameQuoted: true, - tableAlias: "u", - selectStar: false, - columns: [ - { sourceName: "id", sourceNameQuoted: true, resultName: "id", expression: 'u."id"' }, - { sourceName: "full name", sourceNameQuoted: true, resultName: "full name", expression: 'u."full name"' }, - ], - }); + assert.equal(result.analysis.schema, "app schema"); + assert.equal(result.analysis.tableName, "user table"); + assert.equal(result.analysis.tableAlias, "u"); + assert.deepEqual(result.analysis.columns, [ + { sourceName: "id", sourceNameQuoted: true, sourceQualifier: "u", sourceKey: "u:0", resultName: "id", expression: 'u."id"' }, + { sourceName: "full name", sourceNameQuoted: true, sourceQualifier: "u", sourceKey: "u:0", resultName: "full name", expression: 'u."full name"' }, + ]); }); test("keeps the legacy analyzer API for editable SELECT queries", () => { - assert.deepEqual(analyzeEditableQuery("select * from users"), { - schema: undefined, - schemaQuoted: false, - tableName: "users", - tableNameQuoted: false, - tableAlias: undefined, - selectStar: true, - columns: [], - }); + const analysis = analyzeEditableQuery("select * from users"); + assert.ok(analysis); + assert.equal(analysis.tableName, "users"); + assert.equal(analysis.selectStar, true); + assert.deepEqual(analysis.columns, []); }); -test("reports why joined query results are not editable", () => { - const result = analyzeEditableQueryEditability("select u.id, o.total from users u join orders o on o.user_id = u.id"); +test("maps joined query source columns for column-level editing", () => { + const result = analyzeEditableQueryEditability("select u.id as user_id, u.name, o.total from users u join orders o on o.user_id = u.id"); - assert.deepEqual(result, { - editable: false, - reason: "complex-source", - }); - assert.equal(queryEditabilityMessageKey(result.reason), "grid.queryEditUnsupportedComplexSource"); + assert.equal(result.editable, true); + assert.equal(result.analysis.multiSource, true); + assert.equal(result.analysis.allowInsertDelete, false); + assert.deepEqual( + result.analysis.sources?.map((source) => ({ key: source.key, tableName: source.tableName, alias: source.alias })), + [ + { key: "u:0", tableName: "users", alias: "u" }, + { key: "o:1", tableName: "orders", alias: "o" }, + ], + ); + assert.deepEqual(result.analysis.columns, [ + { sourceName: "id", sourceNameQuoted: false, sourceQualifier: "u", sourceKey: "u:0", resultName: "user_id", expression: "u.id" }, + { sourceName: "name", sourceNameQuoted: false, sourceQualifier: "u", sourceKey: "u:0", resultName: "name", expression: "u.name" }, + { sourceName: "total", sourceNameQuoted: false, sourceQualifier: "o", sourceKey: "o:1", resultName: "total", expression: "o.total" }, + ]); + assert.equal(allPrimaryKeysPresent(["id"], ["user_id", "name", "total"], result.analysis, "u:0"), true); + assert.deepEqual(sourceColumnsForResult(result.analysis, ["user_id", "name", "total"], "u:0"), ["id", "name", undefined]); +}); + +test("recognizes single-table explicit columns mixed with alias star", () => { + const result = analyzeEditableQueryEditability("select t.create_date, t.* from tt_kd_material_container_sap t where t.order_no = 'KD2607071336' order by t.create_date desc"); + + assert.equal(result.editable, true); + assert.equal(result.analysis.tableName, "tt_kd_material_container_sap"); + assert.equal(result.analysis.tableAlias, "t"); + assert.deepEqual(result.analysis.columns, [ + { sourceName: "create_date", sourceNameQuoted: false, sourceQualifier: "t", sourceKey: "t:0", resultName: "create_date", expression: "t.create_date" }, + { star: true, sourceQualifier: "t", sourceKey: "t:0", resultName: "*", expression: "t.*" }, + ]); }); test("reports DuckDB external file scans as read-only external sources", () => { @@ -67,7 +77,7 @@ test("reports DuckDB external file scans as read-only external sources", () => { editable: false, reason: "external-source", }); - assert.equal(queryEditabilityMessageKey(result.reason), "grid.queryEditUnsupportedExternalSource"); + assert.equal(queryEditabilityMessageKey(result.reason), "grid.queryEditUnsupported.external-source"); }); test("reports computed result columns as unsafe to edit", () => {