feat(query): support editing without selected primary keys

This commit is contained in:
zipg authored and GitHub committed 2026-07-10 18:57:37 +08:00
1 parent 7b47c81ad7
commit c35e5c560a
11 files changed
+935 -88

No files matched your search

@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes } from "@/lib/sql/editableQueryHiddenKeys";
import { analyzeEditableQueryEditability } from "@/lib/sql/sqlAnalysis";
describe("editable query hidden primary keys", () => {
it("appends quoted MySQL primary keys without changing the visible projection", () => {
expect(
buildQueryWithHiddenPrimaryKeys({
sql: "SELECT name FROM users WHERE active = 1",
databaseType: "mysql",
primaryKeys: ["id"],
existingResultNames: ["name"],
}),
).toEqual({
sql: "SELECT name, `id` AS `__DBX_PK_0` FROM users WHERE active = 1",
projections: [{ sourceName: "id", alias: "__DBX_PK_0" }],
});
});
it("supports PostgreSQL composite primary keys and avoids alias collisions", () => {
const result = buildQueryWithHiddenPrimaryKeys({
sql: 'SELECT "value"\nFROM "items"',
databaseType: "postgres",
primaryKeys: ["tenant_id", "item_id"],
existingResultNames: ["value", "__DBX_PK_0"],
});
expect(result?.sql).toBe('SELECT "value", "tenant_id" AS "__DBX_PK_1", "item_id" AS "__DBX_PK_2"\nFROM "items"');
expect(result?.projections.map((projection) => projection.alias)).toEqual(["__DBX_PK_1", "__DBX_PK_2"]);
});
it("preserves SQL Server TOP and Oracle optimizer hints", () => {
expect(
buildQueryWithHiddenPrimaryKeys({
sql: "SELECT TOP 10 name FROM dbo.users ORDER BY name",
databaseType: "sqlserver",
primaryKeys: ["id"],
existingResultNames: ["name"],
})?.sql,
).toBe("SELECT TOP 10 name, [id] AS [__DBX_PK_0] FROM dbo.users ORDER BY name");
expect(
buildQueryWithHiddenPrimaryKeys({
sql: "SELECT /*+ INDEX(t IDX_USERS_NAME) */ t.NAME\nFROM USERS t",
databaseType: "oracle",
primaryKeys: ["ID"],
existingResultNames: ["NAME"],
})?.sql,
).toBe('SELECT /*+ INDEX(t IDX_USERS_NAME) */ t.NAME, "ID" AS "__DBX_PK_0"\nFROM USERS t');
});
it("inserts hidden keys before a trailing line comment", () => {
expect(
buildQueryWithHiddenPrimaryKeys({
sql: "SELECT name -- visible user name\nFROM users",
databaseType: "mysql",
primaryKeys: ["id"],
existingResultNames: ["name"],
})?.sql,
).toBe("SELECT name, `id` AS `__DBX_PK_0` -- visible user name\nFROM users");
});
it("analyzes SQL Server TOP projections as ordinary source columns", () => {
for (const sql of [
"SELECT TOP 2 name, note FROM dbo.users ORDER BY name",
"SELECT TOP(2) name, note FROM dbo.users ORDER BY name",
"SELECT TOP (2) name, note FROM dbo.users ORDER BY name",
"SELECT TOP 10 PERCENT name, note FROM dbo.users ORDER BY name",
"SELECT TOP (2) WITH TIES name, note FROM dbo.users ORDER BY name",
]) {
const result = analyzeEditableQueryEditability(sql);
expect(result.editable, sql).toBe(true);
if (result.editable) expect(result.analysis.columns.map((column) => column.sourceName)).toEqual(["name", "note"]);
}
});
it("resolves appended aliases to result indexes", () => {
expect(
hiddenResultColumnIndexes(
["name", "__DBX_PK_0", "__DBX_PK_1"],
[
{ sourceName: "tenant_id", alias: "__DBX_PK_0" },
{ sourceName: "item_id", alias: "__DBX_PK_1" },
],
),
).toEqual([1, 2]);
expect(hiddenResultColumnIndexes(["name"], [{ sourceName: "id", alias: "__DBX_PK_0" }])).toEqual([]);
expect(
hiddenResultColumnIndexes(
["name", "__DBX_PK_1"],
[
{ sourceName: "tenant_id", alias: "__DBX_PK_0" },
{ sourceName: "item_id", alias: "__DBX_PK_1" },
],
),
).toEqual([1]);
});
});
@@ -0,0 +1,61 @@
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { DatabaseType } from "@/types/database";
const HIDDEN_PRIMARY_KEY_ALIAS_PREFIX = "__DBX_PK_";
export interface HiddenPrimaryKeyProjection {
sourceName: string;
alias: string;
}
export interface HiddenPrimaryKeyQuery {
sql: string;
projections: HiddenPrimaryKeyProjection[];
}
export function buildQueryWithHiddenPrimaryKeys(options: { sql: string; databaseType: DatabaseType; primaryKeys: string[]; existingResultNames: string[] }): HiddenPrimaryKeyQuery | undefined {
if (options.primaryKeys.length === 0) return undefined;
const dialect = sqlSemanticDialectFor({ databaseType: options.databaseType });
const usedNames = new Set(options.existingResultNames.map((name) => name.toLowerCase()));
const projections = options.primaryKeys.map((sourceName, index) => {
let suffix = index;
let alias = `${HIDDEN_PRIMARY_KEY_ALIAS_PREFIX}${suffix}`;
while (usedNames.has(alias.toLowerCase())) {
suffix += 1;
alias = `${HIDDEN_PRIMARY_KEY_ALIAS_PREFIX}${suffix}`;
}
usedNames.add(alias.toLowerCase());
return { sourceName, alias };
});
const expressions = projections.map(({ sourceName, alias }) => `${dialect.quoteIdentifier(sourceName)} AS ${dialect.quoteIdentifier(alias)}`);
const sql = appendSelectProjections(options.sql, expressions);
return sql ? { sql, projections } : undefined;
}
export function hiddenResultColumnIndexes(columns: string[], projections: HiddenPrimaryKeyProjection[]): number[] {
return projections.flatMap((projection) => {
const index = columns.findIndex((column) => column.toLowerCase() === projection.alias.toLowerCase());
return index < 0 ? [] : [index];
});
}
function appendSelectProjections(sql: string, expressions: string[]): string | undefined {
if (expressions.length === 0) return sql;
const tokens = tokenizeSqlSemantic(sql);
const selectIndex = tokens.findIndex((token) => token.kind === "word" && token.depth === 0 && token.normalized === "select");
if (selectIndex < 0) return undefined;
const fromIndex = tokens.findIndex((token, index) => index > selectIndex && token.kind === "word" && token.depth === 0 && token.normalized === "from");
if (fromIndex < 0) return undefined;
let projectionEnd: number | undefined;
for (let index = fromIndex - 1; index > selectIndex; index -= 1) {
const token = tokens[index];
if (token?.kind === "comment") continue;
projectionEnd = token?.span.end;
break;
}
if (projectionEnd === undefined) return undefined;
return `${sql.slice(0, projectionEnd)}, ${expressions.join(", ")}${sql.slice(projectionEnd)}`;
}
+12 -2
View File
@@ -22,6 +22,7 @@ export interface EditableQueryInfo {
sources?: EditableQuerySource[];
editableSourceKey?: string;
multiSource?: boolean;
allowInsert?: boolean;
allowInsertDelete?: boolean;
distinct?: boolean;
}
@@ -83,10 +84,11 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability {
const rawSelectBody = normalized.slice("SELECT".length, fromIndex).trim();
const distinct = /^DISTINCT\b/i.test(rawSelectBody);
const selectBody = distinct ? rawSelectBody.replace(/^DISTINCT\b/i, "").trimStart() : rawSelectBody;
const selectBodyWithoutDistinct = distinct ? rawSelectBody.replace(/^DISTINCT\b/i, "").trimStart() : rawSelectBody;
// DISTINCT ON selects a representative row using database-specific ordering,
// so it cannot be mapped to base rows like an ordinary DISTINCT projection.
if (distinct && /^ON\b/i.test(selectBody)) return { editable: false, reason: "aggregation" };
if (distinct && /^ON\b/i.test(selectBodyWithoutDistinct)) return { editable: false, reason: "aggregation" };
const selectBody = stripSqlServerTopClause(selectBodyWithoutDistinct);
const groupIndex = findTopLevelKeyword(normalized, "GROUP", fromIndex + 4);
const havingIndex = findTopLevelKeyword(normalized, "HAVING", fromIndex + 4);
@@ -133,6 +135,14 @@ export function queryEditabilityMessageKey(reason: QueryEditabilityReason): stri
return `grid.queryEditUnsupported.${reason}`;
}
function stripSqlServerTopClause(body: string): string {
const topMatch = body.match(/^TOP(?:\s+|(?=\())(?:\((?:[^()'"`[\]]|"[^"]*"|'(?:''|[^'])*'|`[^`]*`|\[[^\]]*\])+\)|\d+)\s*/i);
if (!topMatch) return body;
let remaining = body.slice(topMatch[0].length).trimStart();
remaining = remaining.replace(/^PERCENT\b\s*/i, "").replace(/^WITH\s+TIES\b\s*/i, "");
return remaining || body;
}
function parseSelectColumns(body: string, sources?: EditableQuerySource[]): EditableQueryColumn[] {
const cols: EditableQueryColumn[] = [];
let depth = 0;