mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
fix(grid): show field comments for Oracle joined bare-star queries
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+28
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user