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