feat(query): support broader result editing
This commit is contained in:
1 parent
c6fc5cb909
commit
aa0abcd670
15 files changed
+1238
-333
No files matched your search
@@ -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<ContextMenuItem[]>(() => {
|
||||
{{ t("grid.queryEditReadyHint", { table: queryEditReadyTargetLabel }) }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-else-if="showQueryEditReadOnlyBadge">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="flex h-5 items-center gap-1 rounded border border-muted-foreground/30 bg-muted/60 px-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ t("grid.queryEditReadOnly") }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="max-w-sm">
|
||||
{{ queryEditReadOnlyReason }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="showKeylessEditWarning">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="flex h-5 items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "対象テーブルに主キーがないため、行を安全に更新または削除できません。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "目标表没有主键,无法安全定位要更新或删除的行。",
|
||||
|
||||
@@ -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": "目標資料表沒有主鍵,因此無法安全地更新或刪除資料列。",
|
||||
|
||||
@@ -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<string | undefined> | 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<string | undefined> | 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;
|
||||
});
|
||||
}
|
||||
@@ -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<QueryTab["resultRuns
|
||||
function queryResultSourceLabel(sql: string, database: string | undefined): string | undefined {
|
||||
const analysis = analyzeEditableQuery(sql);
|
||||
if (!analysis) return undefined;
|
||||
// A joined result may still expose writable target-table columns, but its tab
|
||||
// label must not imply that the whole result came from a single table.
|
||||
if (analysis.multiSource || (analysis.sources?.length ?? 0) > 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<void> {
|
||||
@@ -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<QueryTab["tableMeta"]>;
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
Reference in new issue
Block a user