feat(grid): edit simple query results

This commit is contained in:
t8y2
2026-05-16 10:25:05 +08:00
parent d054f04813
commit 2024171c8d
11 changed files with 656 additions and 112 deletions
+47 -3
View File
@@ -42,6 +42,7 @@ import {
Link2,
ListTree,
TableProperties,
LockKeyhole,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
@@ -110,6 +111,8 @@ const props = defineProps<{
connectionId?: string;
database?: string;
context?: "results" | "table-data";
sourceColumns?: Array<string | undefined>;
queryEditabilityReason?: string;
initialWhereInput?: string;
tableMeta?: {
schema?: string;
@@ -949,6 +952,16 @@ const pageSize = ref(settingsStore.editorSettings.pageSize);
const currentPage = ref(1);
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
const isResultsContext = computed(() => props.context === "results");
const resultEditStatus = computed(() => {
if (!isResultsContext.value || !hasData.value) return null;
if (props.editable && props.tableMeta) return "editable";
if (props.queryEditabilityReason) return "readonly";
return null;
});
const queryEditabilityHint = computed(() => {
const reason = props.queryEditabilityReason;
return reason ? t(`grid.queryEditUnsupported.${reason}`) : "";
});
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
const tableUsesSyntheticRowId = computed(() =>
usesSyntheticRowIdKey(props.databaseType, props.tableMeta?.primaryKeys ?? []),
@@ -1079,6 +1092,7 @@ const editor = useDataGridEditor({
connectionId: computed(() => props.connectionId),
database: computed(() => props.database),
tableMeta: computed(() => props.tableMeta),
sourceColumns: computed(() => props.sourceColumns),
canEditExistingRows,
onExecuteSql: computed(() => props.onExecuteSql),
customSave: computed(() => props.customSave),
@@ -1127,6 +1141,7 @@ const {
discardChanges,
rowDataWithChanges,
coerceCellValue,
canEditColumn,
resetGridVerticalScroll,
getResetScrollAfterResult,
clearResetScrollAfterResult,
@@ -1137,6 +1152,10 @@ function canEditRowItem(item: RowItem | undefined): boolean {
return !!props.editable && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value);
}
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
return canEditRowItem(item) && canEditColumn(columnIndex);
}
function canDeleteRowItem(item: RowItem | undefined): boolean {
return !!props.editable && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value);
}
@@ -1447,7 +1466,7 @@ const activeCellDetail = computed(() => {
displayValue,
length: value === null ? 0 : String(value).length,
formattedJson,
isEditable: canEditRowItem(item),
isEditable: canEditCellItem(item, cell.col),
};
});
@@ -2344,6 +2363,31 @@ defineExpose({
<slot name="search-bar" />
<div class="flex shrink-0 items-center gap-1 px-1 ml-auto">
<Tooltip v-if="resultEditStatus === 'editable'">
<TooltipTrigger as-child>
<div
class="flex h-5 items-center gap-1 rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
>
{{ t("grid.queryEditReady") }}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ t("grid.queryEditReadyHint", { table: tableMeta?.tableName }) }}
</TooltipContent>
</Tooltip>
<Tooltip v-else-if="resultEditStatus === 'readonly' && queryEditabilityHint">
<TooltipTrigger as-child>
<div
class="flex h-5 items-center gap-1 rounded border border-border bg-background px-1.5 text-xs font-medium text-muted-foreground"
>
<LockKeyhole class="h-3 w-3" />
{{ t("grid.queryEditReadOnly") }}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ queryEditabilityHint }}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="sm"
@@ -2933,12 +2977,12 @@ defineExpose({
actualColIdx,
),
'tabular-nums': typeof item.data[actualColIdx] === 'number',
'cursor-text hover:bg-accent/50': canEditRowItem(item),
'cursor-text hover:bg-accent/50': canEditCellItem(item, actualColIdx),
'line-through': item.isDeleted,
}"
@mousedown="handleDataCellMousedown(index, visibleColIdx, item.id, $event)"
@mouseenter="extendCellSelection(index, visibleColIdx)"
@dblclick="canEditRowItem(item) && startEdit(item.id, actualColIdx)"
@dblclick="canEditCellItem(item, actualColIdx) && startEdit(item.id, actualColIdx)"
@contextmenu="onCellContext(item.id, index, actualColIdx, visibleColIdx)"
>
<template v-if="editingCell?.rowId === item.id && editingCell?.col === actualColIdx">
+2
View File
@@ -298,6 +298,8 @@ defineExpose({ focusSearch });
:sql="activeTab.lastExecutedSql || activeTab.sql"
:loading="activeTab.isExecuting"
:editable="!!activeTab.queryAnalysis"
:source-columns="activeTab.querySourceColumns"
:query-editability-reason="activeTab.queryEditabilityReason"
context="results"
:database-type="activeConnection?.db_type"
:connection-id="activeTab.connectionId"
+12
View File
@@ -51,6 +51,7 @@ export interface UseDataGridEditorOptions {
}
| undefined
>;
sourceColumns?: ComputedRef<Array<string | undefined> | undefined>;
canEditExistingRows?: ComputedRef<boolean>;
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
customSave?: ComputedRef<
@@ -96,6 +97,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionId,
database,
tableMeta,
sourceColumns = computed(() => undefined),
canEditExistingRows = computed(() => true),
onExecuteSql,
customSave,
@@ -241,6 +243,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return value;
}
function canEditColumn(columnIndex: number): boolean {
const sources = sourceColumns.value;
return !sources || sources[columnIndex] !== undefined;
}
// --- Row data helpers ---
function rowDataWithChanges(row: CellValue[], sourceIndex: number): CellValue[] {
const dirty = dirtyRows.value.get(sourceIndex);
@@ -250,6 +257,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
// --- Inline editing ---
function startEdit(rowId: number, colIdx: number) {
if (!editable.value) return;
if (!canEditColumn(colIdx)) return;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (!item.isNew && !canEditExistingRows.value) return;
@@ -310,6 +318,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function applyCellValue(rowId: number, col: number, value: string | null) {
if (!canEditColumn(col)) return;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
@@ -491,6 +500,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
databaseType: databaseType.value,
tableMeta: tableMeta.value,
columns: result.value.columns,
sourceColumns: sourceColumns.value,
rows: result.value.rows,
dirtyRows: [...dirtyRows.value.entries()].map(
([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>],
@@ -579,6 +589,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
databaseType: databaseType.value,
tableMeta: tableMeta.value,
columns: stmtOptions.columns,
sourceColumns: stmtOptions.sourceColumns,
rows: stmtOptions.rows,
columnInfo: tableMeta.value?.columns,
dirtyRows: stmtOptions.dirtyRows,
@@ -708,6 +719,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
discardChanges,
rowDataWithChanges,
coerceCellValue,
canEditColumn,
resetGridVerticalScroll,
getResetScrollAfterResult,
clearResetScrollAfterResult,
+19
View File
@@ -359,6 +359,25 @@ export default {
commit: "Commit",
rollback: "Rollback",
transactionActive: "Editing",
queryEditReady: "Editable",
queryEditReadyHint:
"This single-table result comes from “{table}”. You can edit, delete, or add rows, then commit to save.",
queryEditReadOnly: "Read-only result",
queryEditUnsupported: {
"not-select": "Only SELECT query results can be edited directly.",
cte: "Queries with WITH/CTE are not editable yet. Use a simple single-table SELECT.",
"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.",
"complex-source": "JOINs, multiple tables, and subqueries cannot be mapped safely to one source row.",
"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.",
"primary-key-not-returned":
"The result is missing the raw primary key column. Include it by its original column name.",
"aliased-columns": "Result columns use aliases or expressions. Select editable columns by their original names.",
"metadata-unavailable": "DBX could not load table metadata, so result editing is disabled.",
},
sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.",
truncatedHint: "Results truncated to 10,000 rows. Use LIMIT/OFFSET in your query to paginate.",
},
+16
View File
@@ -356,6 +356,22 @@ export default {
commit: "提交",
rollback: "回滚",
transactionActive: "编辑中",
queryEditReady: "可编辑",
queryEditReadyHint: "这是来自“{table}”的单表结果,可直接修改、删除或新增行,最后点击提交保存。",
queryEditReadOnly: "只读结果",
queryEditUnsupported: {
"not-select": "只有 SELECT 查询结果可以直接编辑。",
cte: "包含 WITH/CTE 的查询暂不支持直接回写。请改为简单单表 SELECT。",
"set-operation": "包含 UNION、INTERSECT 或 EXCEPT 的结果无法安全定位原表行。",
aggregation: "包含 DISTINCT、GROUP BY、HAVING 或聚合结果,无法直接编辑原始数据。",
"complex-source": "包含 JOIN、多个表或子查询的结果无法安全定位单个原表行。",
"computed-columns": "结果列包含表达式或函数,无法安全回写。请查询原始列名。",
"no-table": "未识别到可编辑的数据表。",
"no-primary-key": "目标表没有主键,无法安全定位要更新或删除的行。",
"primary-key-not-returned": "结果中缺少原始主键列。请把主键列按原列名一起查询出来。",
"aliased-columns": "结果列使用了别名或表达式。请使用原始列名查询需要编辑的列。",
"metadata-unavailable": "无法读取目标表元数据,暂不能启用结果编辑。",
},
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
truncatedHint: "结果已截断,仅显示前 10,000 行。如需更多数据,请使用 LIMIT/OFFSET 分页查询。",
},
+54 -23
View File
@@ -21,6 +21,7 @@ export interface DataGridSaveStatementOptions {
databaseType?: DatabaseType;
tableMeta: DataGridTableMeta;
columns: string[];
sourceColumns?: Array<string | undefined>;
rows: GridCellValue[][];
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
deletedRows: number[];
@@ -31,6 +32,7 @@ export interface DataGridSaveValidationOptions {
databaseType?: DatabaseType;
tableMeta?: DataGridTableMeta;
columns: string[];
sourceColumns?: Array<string | undefined>;
rows?: GridCellValue[][];
columnInfo?: DataGridColumnInfo[];
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
@@ -56,17 +58,20 @@ export function validateDataGridSave(options: DataGridSaveValidationOptions): st
for (const [, changes] of options.dirtyRows) {
for (const [columnIndex, value] of changes) {
const column = options.columns[columnIndex];
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, value)) {
return nullWriteError(column);
const sourceColumn = effectiveColumn(options.sourceColumns, options.columns, columnIndex);
if (!sourceColumn) continue;
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, sourceColumn, value)) {
return nullWriteError(sourceColumn);
}
}
}
for (const row of options.newRows) {
for (const [columnIndex, column] of options.columns.entries()) {
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, row[columnIndex])) {
return nullWriteError(column);
for (const columnIndex of options.columns.keys()) {
const sourceColumn = effectiveColumn(options.sourceColumns, options.columns, columnIndex);
if (!sourceColumn) continue;
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, sourceColumn, row[columnIndex])) {
return nullWriteError(sourceColumn);
}
}
}
@@ -78,7 +83,8 @@ function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): st
const primaryKeys = options.tableMeta?.primaryKeys ?? [];
if (primaryKeys.length === 0 || options.newRows.length === 0) return undefined;
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(options.columns, primaryKey));
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(saveColumns, primaryKey));
if (primaryKeyIndexes.some((index) => index === -1)) return undefined;
const existingKeys = new Set<string>();
@@ -102,6 +108,7 @@ function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): st
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options);
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const table = qualifiedTableName({
databaseType: options.databaseType,
@@ -114,27 +121,29 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
const row = options.rows[rowIndex];
if (!row) continue;
const sets = changes
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]))
.filter(([columnIndex]) => !!saveColumns[columnIndex])
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, saveColumns[columnIndex]))
.map(
([columnIndex, value]) =>
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(value, options.databaseType)}`,
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(value, options.databaseType)}`,
)
.join(", ");
if (!sets) continue;
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, row);
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
}
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, row);
statements.push(`DELETE FROM ${table} WHERE ${where};`);
}
for (const row of options.newRows) {
const insertPairs = options.columns
const insertPairs = saveColumns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
.filter((pair) => !isOracleRowId(options.databaseType, pair.column))
.filter((pair) => pair.value !== null && pair.value !== undefined);
if (!insertPairs.length) continue;
@@ -148,6 +157,7 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options);
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const table = qualifiedTableName({
databaseType: options.databaseType,
@@ -157,15 +167,16 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
const statements: string[] = [];
for (const row of options.newRows) {
const where = buildRowWhere(options.databaseType, options.columns, row);
const where = buildRowWhere(options.databaseType, saveColumns, row);
if (where) statements.push(`DELETE FROM ${table} WHERE ${where};`);
}
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const insertPairs = options.columns
const insertPairs = saveColumns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
.filter((pair) => !isOracleRowId(options.databaseType, pair.column));
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, options.databaseType)).join(", ");
@@ -180,19 +191,19 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
afterRow[columnIndex] = value;
}
const writableChanges = changes.filter(
([columnIndex]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]),
([columnIndex]) => !!saveColumns[columnIndex] && !isOracleRowId(options.databaseType, saveColumns[columnIndex]),
);
const sets = writableChanges
.map(
([columnIndex]) =>
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
)
.join(", ");
if (!sets) continue;
const where = [
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, afterRow),
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, afterRow),
...writableChanges.map(([columnIndex, value]) =>
buildColumnPredicate(options.databaseType, options.columns[columnIndex], value),
buildColumnPredicate(options.databaseType, saveColumns[columnIndex]!, value),
),
]
.filter(Boolean)
@@ -203,6 +214,22 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
return statements;
}
function effectiveColumns(
sourceColumns: Array<string | undefined> | undefined,
columns: string[],
): Array<string | undefined> {
if (!sourceColumns || sourceColumns.length !== columns.length) return columns;
return sourceColumns;
}
function effectiveColumn(
sourceColumns: Array<string | undefined> | undefined,
columns: string[],
index: number,
): string | undefined {
return effectiveColumns(sourceColumns, columns)[index];
}
export function dataGridSaveExecutionSchema(
databaseType: DatabaseType | undefined,
tableMeta: DataGridTableMeta | undefined,
@@ -233,7 +260,7 @@ export function formatGridSqlLiteral(value: GridCellValue, databaseType?: Databa
function buildPrimaryKeyWhere(
databaseType: DatabaseType | undefined,
primaryKeys: string[],
columns: string[],
columns: Array<string | undefined>,
row: GridCellValue[],
): string {
if (databaseType === "hive" && primaryKeys.length === 0) return buildRowWhere(databaseType, columns, row);
@@ -245,10 +272,14 @@ function buildPrimaryKeyWhere(
.join(" AND ");
}
function buildRowWhere(databaseType: DatabaseType | undefined, columns: string[], row: GridCellValue[]): string {
function buildRowWhere(
databaseType: DatabaseType | undefined,
columns: Array<string | undefined>,
row: GridCellValue[],
): string {
return columns
.map((column, index) =>
isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]),
!column || isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]),
)
.filter(Boolean)
.join(" AND ");
@@ -283,9 +314,9 @@ function isNullWriteToNotNullColumn(
return value === null && notNullColumns.has(normalizeColumnName(column));
}
function findColumnIndex(columns: string[], target: string): number {
function findColumnIndex(columns: Array<string | undefined>, target: string): number {
const normalizedTarget = normalizeColumnName(target);
return columns.findIndex((column) => normalizeColumnName(column) === normalizedTarget);
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);
}
function primaryKeyValueKey(primaryKeyIndexes: number[], row: GridCellValue[]): string | undefined {
+279 -81
View File
@@ -21,10 +21,34 @@ export function isBinaryType(dataType: string): boolean {
export interface EditableQueryInfo {
schema: string | undefined;
tableName: string;
tableAlias?: string;
selectStar: boolean;
columns: string[]; // empty array if SELECT *
columns: EditableQueryColumn[]; // empty array if SELECT *
}
export interface EditableQueryColumn {
sourceName?: string;
resultName: string;
expression: string;
}
export type QueryEditabilityReason =
| "not-select"
| "cte"
| "set-operation"
| "aggregation"
| "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:
@@ -33,122 +57,296 @@ export interface EditableQueryInfo {
* - WHERE, ORDER BY, LIMIT are allowed
*/
export function analyzeEditableQuery(sql: string): EditableQueryInfo | null {
const trimmed = sql.trim();
const result = analyzeEditableQueryEditability(sql);
return result.editable ? result.analysis : null;
}
// Strip trailing semicolons and whitespace
const cleaned = trimmed.replace(/;+\s*$/, "").trim();
// Must start with SELECT (case-insensitive)
if (!/^SELECT\b/i.test(cleaned)) return null;
// Reject CTEs (WITH clause before SELECT)
if (/^\s*WITH\b/i.test(trimmed)) return null;
// Remove inline comments and block comments
const normalized = cleaned.replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
// Reject UNION, INTERSECT, EXCEPT
if (/\b(UNION|INTERSECT|EXCEPT)\b/i.test(normalized)) return null;
// --- Extract SELECT body (columns) ---
// Match everything between SELECT and the first SQL clause keyword (FROM, WHERE, ORDER, etc.)
const selectMatch = normalized.match(
/^SELECT\s+(.+?)(?:\bFROM\b|\bWHERE\b|\bORDER\b|\bLIMIT\b|\bGROUP\b|\bHAVING\b|$)/is,
);
if (!selectMatch) return null;
const selectBody = selectMatch[1].trim();
// Reject DISTINCT
if (/^\s*DISTINCT\b/i.test(selectBody)) return null;
// Check if SELECT *
const selectStar = /^\s*\*\s*$/.test(selectBody);
const columns: string[] = [];
if (!selectStar) {
columns.push(...parseSelectColumns(selectBody));
export function analyzeEditableQueryEditability(sql: string): QueryEditability {
const normalized = stripSqlComments(sql)
.replace(/;+\s*$/, "")
.trim();
if (!normalized) return { editable: false, reason: "not-select" };
if (/^\s*WITH\b/i.test(normalized)) return { editable: false, reason: "cte" };
if (!/^SELECT\b/i.test(normalized)) return { editable: false, reason: "not-select" };
if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT"])) {
return { editable: false, reason: "set-operation" };
}
if (normalized.includes(";")) return { editable: false, reason: "complex-source" };
// --- Extract FROM body ---
const fromMatch = normalized.match(/\bFROM\s+(.+?)(?:\bWHERE\b|\bORDER\b|\bLIMIT\b|\bGROUP\b|\bHAVING\b|$)/is);
if (!fromMatch) return null;
const fromBody = fromMatch[1].trim();
const fromIndex = findTopLevelKeyword(normalized, "FROM", 0);
if (fromIndex < 0) return { editable: false, reason: "no-table" };
// Reject JOIN
if (/\bJOIN\b/i.test(fromBody)) return null;
const selectBody = normalized.slice("SELECT".length, fromIndex).trim();
if (/^DISTINCT\b/i.test(selectBody)) return { editable: false, reason: "aggregation" };
// Reject GROUP BY, HAVING (already partially covered but double-check)
if (/\b(GROUP\s+BY|HAVING)\b/i.test(fromBody)) return null;
const groupIndex = findTopLevelKeyword(normalized, "GROUP", fromIndex + 4);
const havingIndex = findTopLevelKeyword(normalized, "HAVING", fromIndex + 4);
if (groupIndex >= 0 || havingIndex >= 0) return { editable: false, reason: "aggregation" };
// Reject subqueries: if FROM body contains SELECT keyword
if (/\bSELECT\b/i.test(fromBody)) return null;
const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"], fromIndex + 4);
const fromBody = normalized.slice(fromIndex + 4, fromEnd < 0 ? normalized.length : fromEnd).trim();
const source = parseFromSource(fromBody);
if (!source) return { editable: false, reason: "complex-source" };
// Also reject if there's a SELECT anywhere after FROM (subquery in WHERE etc.)
const fromIdx = normalized.toUpperCase().indexOf("FROM");
if (fromIdx >= 0) {
const afterFirstFrom = normalized.slice(fromIdx);
// Count SELECT occurrences after FROM — should be 0 (the original SELECT is before FROM)
const selectCount = (afterFirstFrom.match(/\bSELECT\b/g) || []).length;
if (selectCount > 0) return null;
}
// --- Extract table name from FROM clause ---
// Strip all quoting characters (backticks, double quotes, square brackets)
const stripped = fromBody.replace(/[`"[\]]/g, "").trim();
// Match: table OR schema.table
const tableMatch = stripped.match(/^(\w+)(?:\.(\w+))?/);
if (!tableMatch) return null;
const schema = tableMatch[2] ? tableMatch[1] : undefined;
const tableName = tableMatch[2] || tableMatch[1];
const selectStar = isSelectStar(selectBody, source.alias);
const columns = selectStar ? [] : parseSelectColumns(selectBody);
if (!selectStar && columns.length === 0) return { editable: false, reason: "computed-columns" };
return {
schema,
tableName,
selectStar,
columns,
editable: true,
analysis: {
schema: source.schema,
tableName: source.tableName,
tableAlias: source.alias,
selectStar,
columns,
},
};
}
function parseSelectColumns(body: string): string[] {
const cols: string[] = [];
export function queryEditabilityMessageKey(reason: QueryEditabilityReason): string {
return {
"not-select": "grid.queryEditUnsupportedNotSelect",
cte: "grid.queryEditUnsupportedCte",
"set-operation": "grid.queryEditUnsupportedSetOperation",
aggregation: "grid.queryEditUnsupportedAggregation",
"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];
}
function parseSelectColumns(body: string): EditableQueryColumn[] {
const cols: EditableQueryColumn[] = [];
let depth = 0;
let current = "";
let quote: string | null = null;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch === "(") depth++;
if (quote) {
current += ch;
if (ch === quote || (quote === "]" && ch === "]")) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") quote = ch;
else if (ch === "[") quote = "]";
else if (ch === "(") depth++;
else if (ch === ")") depth--;
else if (ch === "," && depth === 0) {
cols.push(extractColumnName(current.trim()));
const col = parseSelectColumn(current.trim());
if (!col) return [];
cols.push(col);
current = "";
continue;
}
current += ch;
}
if (current.trim()) {
cols.push(extractColumnName(current.trim()));
const col = parseSelectColumn(current.trim());
if (!col) return [];
cols.push(col);
}
return cols;
}
function extractColumnName(col: string): string {
// Handle AS alias
const asMatch = col.match(/\bAS\s+(\w+)/i);
if (asMatch) return asMatch[1];
function parseSelectColumn(col: string): EditableQueryColumn | null {
const source = parseQualifiedIdentifier(col);
if (!source) return parseComputedSelectColumn(col);
const alias = parseColumnAlias(source.rest);
if (alias === null) return parseComputedSelectColumn(col);
return {
sourceName: source.parts[source.parts.length - 1],
resultName: alias ?? source.parts[source.parts.length - 1],
expression: col.slice(0, source.end).trim(),
};
}
// Strip quoting characters
const stripped = col.replace(/[`"'[\]]/g, "");
function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
const alias = parseExpressionAlias(col);
if (!alias) return null;
return {
sourceName: undefined,
resultName: alias.resultName,
expression: alias.expression,
};
}
// Take last identifier (handles table.column -> column)
const parts = stripped.split(".");
return parts[parts.length - 1] || stripped;
function parseExpressionAlias(col: string): { expression: string; resultName: string } | null {
const asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:[A-Za-z_][\w$]*))\s*$/i);
if (asMatch?.index === undefined) return null;
const alias = readIdentifier(asMatch[1], 0);
if (!alias || alias.end !== asMatch[1].length) return null;
const expression = col.slice(0, asMatch.index).trim();
return expression ? { expression, resultName: alias.value } : null;
}
function parseColumnAlias(rest: string): string | undefined | null {
const trimmed = rest.trim();
if (!trimmed) return undefined;
const asMatch = trimmed.match(/^AS\s+/i);
const aliasText = asMatch ? trimmed.slice(asMatch[0].length).trim() : trimmed;
const alias = readIdentifier(aliasText, 0);
if (!alias || alias.end !== aliasText.length) return null;
return alias.value;
}
function isSelectStar(body: string, alias: string | undefined): boolean {
const trimmed = body.trim();
if (trimmed === "*") return true;
if (!alias) return false;
return new RegExp(`^${escapeRegExp(alias)}\\s*\\.\\s*\\*$`, "i").test(trimmed);
}
function parseFromSource(body: string): { schema?: string; tableName: string; 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;
}
const tableName = ident.parts[ident.parts.length - 1];
const schema = ident.parts.length === 2 ? ident.parts[0] : undefined;
return { schema, tableName, alias };
}
function parseQualifiedIdentifier(text: string): { parts: string[]; end: number; rest: string; done: boolean } | null {
const parts: string[] = [];
let pos = 0;
while (pos < text.length) {
pos = skipWhitespace(text, pos);
const ident = readIdentifier(text, pos);
if (!ident) break;
parts.push(ident.value);
pos = skipWhitespace(text, ident.end);
if (text[pos] !== ".") break;
pos++;
}
if (parts.length === 0) return null;
return { parts, end: pos, rest: text.slice(pos), done: text.slice(pos).trim() === "" };
}
function readIdentifier(text: string, start: number): { value: string; end: number } | null {
const pos = skipWhitespace(text, start);
const quote = text[pos];
if (quote === '"' || quote === "`" || quote === "[") {
const close = quote === "[" ? "]" : quote;
let value = "";
for (let i = pos + 1; i < text.length; i++) {
if (text[i] === close) return { value, end: i + 1 };
value += text[i];
}
return null;
}
const match = text.slice(pos).match(/^[A-Za-z_][\w$]*/);
return match ? { value: match[0], end: pos + match[0].length } : null;
}
function skipWhitespace(text: string, pos: number): number {
while (pos < text.length && /\s/.test(text[pos])) pos++;
return pos;
}
function stripSqlComments(sql: string): string {
return sql.replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
}
function hasTopLevelKeyword(sql: string, keywords: string[]): boolean {
return keywords.some((keyword) => findTopLevelKeyword(sql, keyword, 0) >= 0);
}
function firstTopLevelKeywordIndex(sql: string, keywords: string[], start: number): number {
const indexes = keywords.map((keyword) => findTopLevelKeyword(sql, keyword, start)).filter((index) => index >= 0);
return indexes.length ? Math.min(...indexes) : -1;
}
function findTopLevelKeyword(sql: string, keyword: string, start: number): number {
let depth = 0;
let quote: string | null = null;
const upperKeyword = keyword.toUpperCase();
for (let i = start; i < sql.length; i++) {
const ch = sql[i];
if (quote) {
if (ch === quote || (quote === "]" && ch === "]")) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
quote = ch;
continue;
}
if (ch === "[") {
quote = "]";
continue;
}
if (ch === "(") {
depth++;
continue;
}
if (ch === ")") {
depth = Math.max(0, depth - 1);
continue;
}
if (depth !== 0) continue;
if (sql.slice(i, i + keyword.length).toUpperCase() !== upperKeyword) continue;
const before = i === 0 ? "" : sql[i - 1];
const after = sql[i + keyword.length] ?? "";
if (!isIdentifierChar(before) && !isIdentifierChar(after)) return i;
}
return -1;
}
function isIdentifierChar(ch: string): boolean {
return /[\w$]/.test(ch);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Check if all primary key columns are present in the result set columns.
* Comparison is case-insensitive.
*/
export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[]): boolean {
export function allPrimaryKeysPresent(
primaryKeys: string[],
resultColumns: string[],
analysis?: EditableQueryInfo,
): boolean {
if (analysis && !analysis.selectStar) {
const sourceColumns = new Set(
analysis.columns.flatMap((column) => (column.sourceName ? [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;
});
}
export function sourceColumnsForResult(
analysis: EditableQueryInfo,
resultColumns: 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;
}
+54 -4
View File
@@ -6,7 +6,12 @@ import { orderPinnedFirst } from "@/lib/pinnedItems";
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan";
import { analyzeEditableQuery, allPrimaryKeysPresent } from "@/lib/sqlAnalysis";
import {
allEditableColumnsWriteable,
allPrimaryKeysPresent,
analyzeEditableQueryEditability,
sourceColumnsForResult,
} from "@/lib/sqlAnalysis";
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
import { mongoDocumentsToQueryResult, parseMongoFindCommand } from "@/lib/mongoShellCommand";
import * as api from "@/lib/api";
@@ -171,6 +176,9 @@ export const useQueryStore = defineStore("query", () => {
tab.sql = sql;
tab.resultSortedSql = undefined;
tab.resultBaseSql = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
}
}
@@ -224,6 +232,9 @@ export const useQueryStore = defineStore("query", () => {
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
}
@@ -245,6 +256,9 @@ export const useQueryStore = defineStore("query", () => {
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
}
@@ -315,19 +329,25 @@ export const useQueryStore = defineStore("query", () => {
if (tab.mode !== "query") return;
if (!tab.result || !tab.result.columns.length) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.tableMeta = undefined;
return;
}
const analysis = analyzeEditableQuery(sql);
if (!analysis) {
const editability = analyzeEditableQueryEditability(sql);
if (!editability.editable) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = editability.reason;
tab.tableMeta = undefined;
return;
}
const analysis = editability.analysis;
if (!tab.connectionId || !tab.database) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "metadata-unavailable";
tab.tableMeta = undefined;
return;
}
@@ -353,15 +373,35 @@ export const useQueryStore = defineStore("query", () => {
primaryKeys,
};
if (primaryKeys.length === 0 || !allPrimaryKeysPresent(primaryKeys, tab.result.columns)) {
if (primaryKeys.length === 0) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "no-primary-key";
return;
}
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, analysis)) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "primary-key-not-returned";
return;
}
if (!allEditableColumnsWriteable(analysis, tab.result.columns)) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "aliased-columns";
return;
}
tab.queryAnalysis = analysis;
tab.querySourceColumns = sourceColumnsForResult(analysis, tab.result.columns);
tab.queryEditabilityReason = undefined;
} catch (err) {
console.error("[DBX] ERROR fetching columns for query metadata:", err);
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "metadata-unavailable";
tab.tableMeta = undefined;
}
}
@@ -418,6 +458,8 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined;
current.result = mongoDocumentsToQueryResult(result.documents, performance.now() - startedAt, result.total);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
@@ -465,6 +507,8 @@ export const useQueryStore = defineStore("query", () => {
current.results = undefined;
current.activeResultIndex = undefined;
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryEditabilityReason = undefined;
if (current.mode !== "data") current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
@@ -578,6 +622,9 @@ export const useQueryStore = defineStore("query", () => {
if (!tab?.results || index < 0 || index >= tab.results.length) return;
tab.activeResultIndex = index;
tab.result = tab.results[index];
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
}
function trimResultCache() {
@@ -586,6 +633,9 @@ export const useQueryStore = defineStore("query", () => {
const toEvict = inactive.slice(0, inactive.length - MAX_CACHED_RESULTS);
toEvict.forEach((t) => {
t.result = undefined;
t.queryAnalysis = undefined;
t.querySourceColumns = undefined;
t.queryEditabilityReason = undefined;
});
}
}
+19 -1
View File
@@ -284,9 +284,27 @@ export interface QueryTab {
queryAnalysis?: {
schema?: string;
tableName: string;
tableAlias?: string;
selectStar: boolean;
columns: string[];
columns: {
sourceName?: string;
resultName: string;
expression: string;
}[];
};
querySourceColumns?: Array<string | undefined>;
queryEditabilityReason?:
| "not-select"
| "cte"
| "set-operation"
| "aggregation"
| "complex-source"
| "computed-columns"
| "no-table"
| "no-primary-key"
| "primary-key-not-returned"
| "aliased-columns"
| "metadata-unavailable";
whereInput?: string;
}