feat(query): support editing without selected primary keys
This commit is contained in:
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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user