mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
feat(grid): edit simple query results
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user