feat(query): support broader result editing

This commit is contained in:
t8y2 committed 2026-07-09 14:35:59 +08:00
1 parent c6fc5cb909
commit aa0abcd670
15 files changed
+1238 -333

No files matched your search

+19 -1
View File
@@ -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"
+2 -2
View File
@@ -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.",
+2 -2
View File
@@ -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.",
+2 -2
View File
@@ -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.",
+2 -2
View File
@@ -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": "対象テーブルに主キーがないため、行を安全に更新または削除できません。",
+2 -2
View File
@@ -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.",
+2 -2
View File
@@ -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": "目标表没有主键,无法安全定位要更新或删除的行。",
+2 -2
View File
@@ -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": "目標資料表沒有主鍵,因此無法安全地更新或刪除資料列。",
+216 -65
View File
@@ -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;
});
}
+234 -77
View File
@@ -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);
+18
View File
@@ -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;
}[];