fix(grid): show field comments for Oracle joined bare-star queries

This commit is contained in:
zipg
2026-09-17 12:55:48 +08:00
committed by GitHub
parent ee6078f195
commit f9dd85b785
4 changed files with 82 additions and 19 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis";
import { analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis";
/**
* Result columns resolve by projection ordinal, each carrying its source
@@ -7,6 +7,27 @@ import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from "
* per-source comments instead of first-source-wins on name clashes.
*/
describe("multi-source result column mapping", () => {
it("expands a bare star in a joined result for display metadata", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT * FROM orders a JOIN users b ON a.user_id = b.id");
expect(analysis?.selectStar).toBe(true);
const resolved = resolveSourceColumnsByOrdinal(
"oracle",
analysis!,
[
{ source: analysis!.sources![0]!, columns: [{ name: "ID" }, { name: "USER_ID" }] },
{ source: analysis!.sources![1]!, columns: [{ name: "ID" }, { name: "NAME" }] },
],
4,
);
expect(resolved).toEqual([
{ sourceKey: "a:0", sourceColumn: "ID" },
{ sourceKey: "a:0", sourceColumn: "USER_ID" },
{ sourceKey: "b:1", sourceColumn: "ID" },
{ sourceKey: "b:1", sourceColumn: "NAME" },
]);
});
it("parses a JOIN as multi-source with per-source columns", () => {
const result = analyzeEditableQueryEditability("SELECT a.id, a.user_id, b.name FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
+4 -1
View File
@@ -338,7 +338,10 @@ export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo
const hasWindowClause = findTopLevelKeyword(normalized, "OVER", "SELECT".length) >= 0 || findTopLevelKeyword(normalized, "WINDOW", fromIndex + "FROM".length) >= 0;
const hasRightJoinClause = hasTopLevelRightJoin(fromBody);
const selectStar = sources.length === 1 && isSelectStar(selectBody, source.alias);
// A bare `*` in a multi-source query expands in source/projection order.
// Keep it as a display-only star so joined result columns can still resolve
// to their physical metadata without making the query editable.
const selectStar = isSelectStar(selectBody, source.alias);
const columns = selectStar ? [] : parseSelectColumns(selectBody, sources);
if (!selectStar && columns.length === 0) return null;
if (sources.length > 1 && columns.some((column) => column.star && !column.sourceKey)) return null;
@@ -152,6 +152,34 @@ describe("queryStore multi-source result column comments", () => {
]);
});
it("loads display comments for a joined bare-star result without enabling edits", async () => {
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "XE", query_timeout_secs: 30 });
analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "complex-source" });
const oracleOrdersColumns = ordersColumns.map((item) => ({ ...item, name: item.name.toUpperCase() }));
const oracleUsersColumns = usersColumns.map((item) => ({ ...item, name: item.name.toUpperCase() }));
getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => (table === "ORDERS_10K" ? oracleOrdersColumns : oracleUsersColumns));
executeMulti.mockResolvedValue([
{
columns: ["ID", "ORDER_NO", "ID", "NAME"],
rows: [[1, "O1", 7, "Alice"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "XE", "Query");
await store.executeTabSql(tabId, "SELECT * FROM ORDERS_10K a JOIN USERS b ON a.ID = b.ID");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
expect(tab.resultColumnComments).toEqual(["订单ID", "下单用户", "订单金额", "用户ID"]);
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.queryEditabilityReason).toBe("complex-source");
});
it("resolves a uniquely qualified unqualified alias back to its physical column", async () => {
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
+28 -17
View File
@@ -5626,14 +5626,21 @@ export const useQueryStore = defineStore("query", () => {
}
/**
* Resolve grouped result columns by projection ordinal. All databases use the
* mapping for comments. MySQL may additionally edit direct columns from one
* uniquely identifiable base table; aggregate expressions and columns from
* every other source remain read-only.
* Resolve read-only query result columns by projection ordinal. All databases
* use the mapping for comments. MySQL aggregation may additionally edit
* direct columns from one uniquely identifiable base table.
*/
async function resolveAggregationQueryMetadata(tab: QueryTab, sql: string, executionDatabase: string, traceId: string | undefined): Promise<QueryMetadataPatch | undefined> {
async function resolveQueryDisplayMetadata(
tab: QueryTab,
sql: string,
executionDatabase: string,
traceId: string | undefined,
connection: ConnectionConfig | undefined,
allowMysqlEditing = true,
readOnlyReason: "aggregation" | "complex-source" = "aggregation",
): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query" || !tab.connectionId || !tab.result || !tab.result.columns.length) return undefined;
const conn = useConnectionStore().getConfig(tab.connectionId);
const conn = connection ?? useConnectionStore().getConfig(tab.connectionId);
const dbType = conn?.db_type || "";
const analysis = analyzeSelectStructureForDisplay(sql);
if (!analysis) return undefined;
@@ -5664,12 +5671,12 @@ export const useQueryStore = defineStore("query", () => {
const readOnlyPatch: QueryMetadataPatch = {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "aggregation",
queryEditabilityReason: readOnlyReason,
tableMeta: undefined,
resultColumnComments: displayInfo.comments,
queryDisplaySourceColumns: displayInfo.mapping,
};
if (dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch;
if (!allowMysqlEditing || dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch;
// Mutation safety boundary: the FROM root must remain on the preserved
// side of the join tree, and GROUP BY must resolve to exactly that table's
// declared primary key. This makes every editable result row identify one
@@ -5723,7 +5730,7 @@ export const useQueryStore = defineStore("query", () => {
}
}
async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise<QueryMetadataPatch | undefined> {
async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [], connection?: ConnectionConfig): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query") return;
if (!tab.result || !tab.result.columns.length) {
return {
@@ -5743,8 +5750,13 @@ export const useQueryStore = defineStore("query", () => {
elapsed: elapsed?.(),
});
if (!editability.editable) {
const aggregationPatch = editability.reason === "aggregation" ? await resolveAggregationQueryMetadata(tab, sql, executionDatabase, traceId) : undefined;
if (aggregationPatch) return aggregationPatch;
let displayPatch: QueryMetadataPatch | undefined;
if (editability.reason === "aggregation") {
displayPatch = await resolveQueryDisplayMetadata(tab, sql, executionDatabase, traceId, connection);
} else if (editability.reason === "complex-source") {
displayPatch = await resolveQueryDisplayMetadata(tab, sql, executionDatabase, traceId, connection, false, "complex-source");
}
if (displayPatch) return displayPatch;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
@@ -5763,8 +5775,7 @@ export const useQueryStore = defineStore("query", () => {
};
}
const connStore = useConnectionStore();
const conn = connStore.getConfig(tab.connectionId);
const conn = connection ?? useConnectionStore().getConfig(tab.connectionId);
const dbType = conn?.db_type || "";
const sources = editableQuerySources(analysis);
const loadedSources: LoadedEditableSource[] = [];
@@ -5915,14 +5926,14 @@ export const useQueryStore = defineStore("query", () => {
}
}
function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []) {
function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [], connection?: ConnectionConfig) {
void (async () => {
const tab = tabs.value.find((t) => t.id === tabId);
if (!tab || tab.result !== result) return;
queryExecutionLog("info", "metadata:start", { traceId, elapsed: elapsed() });
// Metadata requests outlive the displayed result when another query starts
// or a retained run is selected. Analyze this result, not the live tab.
const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys);
const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys, connection);
if (patch?.queryAnalysis && hasHiddenPhysicalRowKey(databaseType, hiddenPrimaryKeys)) {
patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false };
}
@@ -7365,7 +7376,7 @@ export const useQueryStore = defineStore("query", () => {
elapsed: elapsed(),
});
if (current.mode === "query" && current.result) {
analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, effectiveDbType, hiddenPrimaryKeys);
analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, effectiveDbType, hiddenPrimaryKeys, conn);
}
} else {
queryExecutionLog("warn", "stale-result", {
@@ -7981,7 +7992,7 @@ export const useQueryStore = defineStore("query", () => {
const metadataStartedAt = performance.now();
const connection = useConnectionStore().getConfig(tab.connectionId);
const executionDatabase = dataTabExecutionDatabase(connection, tab.database, tab.catalog);
analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`, effectiveDatabaseTypeForConnection(connection));
analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`, effectiveDatabaseTypeForConnection(connection), [], connection);
}
}