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,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;