feat(editor): dialect-aware SQL quick actions with per-database templates

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-17 17:29:01 +08:00
1 parent 455117b8c5
commit 65b820e6a9
30 files changed
+1541 -150

No files matched your search

@@ -0,0 +1,64 @@
import type { DatabaseType } from "@/types/database";
/** Default row limit for built-in `sel` snippets. */
export const DEFAULT_SELECT_ROW_LIMIT = 100;
/** Default row limit for SQL quick-action "select first N" kind. */
export const DEFAULT_SQL_SHORTCUT_SELECT_LIMIT = 10;
export type SelectLimitStyle = "limit" | "top" | "first" | "fetch-first" | "rows" | "rownum";
const SELECT_LIMIT_STYLE_BY_DATABASE: Partial<Record<DatabaseType, SelectLimitStyle>> = {
oracle: "rownum",
"oceanbase-oracle": "rownum",
oscar: "rownum",
dameng: "rownum",
db2: "fetch-first",
sqlserver: "top",
access: "top",
iris: "top",
teradata: "top",
informix: "first",
firebird: "rows",
// Unknown JDBC dialects fall through to LIMIT (most common / safest default).
// Known JDBC profiles are converted to their effective database type before
// reaching the editor.
};
export function selectLimitStyleForDatabase(databaseType?: DatabaseType): SelectLimitStyle {
return databaseType ? (SELECT_LIMIT_STYLE_BY_DATABASE[databaseType] ?? "limit") : "limit";
}
/**
* Build a dialect-aware `SELECT * FROM <table>` with a row limit.
* `table` is inserted as-is (caller supplies quoting / `${table}` token).
*/
export function buildSelectStarWithLimitSql(table: string, limit: number, databaseType?: DatabaseType): string {
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_SELECT_ROW_LIMIT;
const style = selectLimitStyleForDatabase(databaseType);
switch (style) {
case "top":
return `SELECT TOP ${safeLimit} *\nFROM ${table}`;
case "first":
return `SELECT FIRST ${safeLimit} *\nFROM ${table}`;
case "fetch-first":
return `SELECT *\nFROM ${table}\nFETCH FIRST ${safeLimit} ROWS ONLY`;
case "rows":
return `SELECT *\nFROM ${table}\nROWS ${safeLimit}`;
case "rownum":
return `SELECT *\nFROM ${table}\nWHERE ROWNUM <= ${safeLimit}`;
case "limit":
return `SELECT *\nFROM ${table}\nLIMIT ${safeLimit}`;
}
}
/**
* Snippet bodies keep a trailing semicolon for insert-at-cursor UX.
* An unknown JDBC driver may expose any SQL dialect, so the snippet stays
* unbounded instead of guessing LIMIT; quick actions remain bounded via
* `buildSelectStarWithLimitSql` regardless.
*/
export function buildSelectSnippetBody(databaseType?: DatabaseType, limit = DEFAULT_SELECT_ROW_LIMIT): string {
if (databaseType === "jdbc") return "SELECT *\nFROM table;";
return `${buildSelectStarWithLimitSql("table", limit, databaseType)};`;
}
+144 -3
View File
@@ -1,23 +1,164 @@
import { canonicalShortcutKey } from "@/lib/editor/shortcutDisplay";
import { formatShortcut, SHORTCUT_DEFINITIONS, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
import type { SqlShortcutAction } from "@/types/database";
import { buildSelectStarWithLimitSql, DEFAULT_SQL_SHORTCUT_SELECT_LIMIT } from "@/lib/sql/sqlDialectSelectLimit";
import type { DatabaseType, SqlShortcutAction, SqlShortcutKind } from "@/types/database";
import { DATABASE_TYPES } from "@/types/generated/databaseTypes";
export const SQL_SHORTCUT_TABLE_TOKEN = "${table}";
export const BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID = "builtin-select-limit";
export const BUILTIN_SQL_SHORTCUT_COUNT_ID = "builtin-count";
const DATABASE_TYPE_SET = new Set<string>(DATABASE_TYPES);
/** Built-in quick actions shipped with empty / missing settings. */
export const DEFAULT_SQL_SHORTCUTS: SqlShortcutAction[] = [
{
id: BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID,
label: "Select first N rows",
shortcut: "Mod+Shift+1",
kind: "select-limit",
limit: DEFAULT_SQL_SHORTCUT_SELECT_LIMIT,
sql: buildSelectStarWithLimitSql(SQL_SHORTCUT_TABLE_TOKEN, DEFAULT_SQL_SHORTCUT_SELECT_LIMIT),
enabled: true,
},
{
id: BUILTIN_SQL_SHORTCUT_COUNT_ID,
label: "Count rows",
shortcut: "Mod+Shift+2",
sql: `SELECT COUNT(*) AS row_count FROM ${SQL_SHORTCUT_TABLE_TOKEN}`,
enabled: true,
},
];
export function isBuiltinSqlShortcut(id: string): boolean {
return id === BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID || id === BUILTIN_SQL_SHORTCUT_COUNT_ID;
}
/** Append any missing built-in ids without overwriting user-edited entries. */
export function mergeDefaultSqlShortcuts(actions: readonly SqlShortcutAction[]): SqlShortcutAction[] {
const ids = new Set(actions.map((action) => action.id));
const missing = DEFAULT_SQL_SHORTCUTS.filter((action) => !ids.has(action.id)).map((action) => ({ ...action }));
return missing.length > 0 ? [...actions, ...missing] : [...actions];
}
export function resolveSqlShortcutTemplate(template: string, selectedTable: string): string {
return template.replace(/\$\{table\}/g, () => selectedTable.trim());
}
export function normalizeSqlShortcutLimit(value: unknown, fallback = DEFAULT_SQL_SHORTCUT_SELECT_LIMIT): number {
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(n) || n < 1) return fallback;
return Math.min(Math.floor(n), 100_000);
}
export function normalizeSqlShortcutKind(value: unknown): SqlShortcutKind {
return value === "select-limit" ? "select-limit" : "template";
}
export function normalizeSqlShortcutDatabaseTypes(value: unknown): DatabaseType[] | undefined {
if (!Array.isArray(value) || value.length === 0) return undefined;
const valid: DatabaseType[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string" || !DATABASE_TYPE_SET.has(item) || seen.has(item)) continue;
seen.add(item);
valid.push(item as DatabaseType);
}
return valid.length > 0 ? valid : undefined;
}
export function normalizeSqlShortcutSqlByDatabaseType(value: unknown): Partial<Record<DatabaseType, string>> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Partial<Record<DatabaseType, string>> = {};
for (const [key, sql] of Object.entries(value as Record<string, unknown>)) {
if (!DATABASE_TYPE_SET.has(key) || typeof sql !== "string") continue;
result[key as DatabaseType] = sql;
}
return Object.keys(result).length > 0 ? result : undefined;
}
/** When overrides exist without an explicit scope, treat override keys as the database scope. */
export function deriveSqlShortcutDatabaseTypes(databaseTypes: DatabaseType[] | undefined, sqlByDatabaseType: Partial<Record<DatabaseType, string>> | undefined): DatabaseType[] | undefined {
if (databaseTypes && databaseTypes.length > 0) return databaseTypes;
if (!sqlByDatabaseType) return undefined;
const derived = Object.keys(sqlByDatabaseType).filter((key): key is DatabaseType => DATABASE_TYPE_SET.has(key));
return derived.length > 0 ? derived : undefined;
}
export function resolveSqlShortcutBody(action: Pick<SqlShortcutAction, "sql" | "sqlByDatabaseType">, databaseType?: DatabaseType): string {
if (databaseType && action.sqlByDatabaseType?.[databaseType] != null && action.sqlByDatabaseType[databaseType] !== "") {
return action.sqlByDatabaseType[databaseType]!;
}
return action.sql;
}
export function canonicalSqlShortcutSql(action: Pick<SqlShortcutAction, "kind" | "limit" | "sql">): string {
if (normalizeSqlShortcutKind(action.kind) !== "select-limit") return action.sql;
const limit = normalizeSqlShortcutLimit(action.limit);
return buildSelectStarWithLimitSql(SQL_SHORTCUT_TABLE_TOKEN, limit);
}
export function enabledSqlShortcutActions(actions: readonly SqlShortcutAction[]): SqlShortcutAction[] {
return actions.filter((action) => action.enabled !== false && action.shortcut.trim().length > 0);
}
function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean {
export function sqlShortcutAppliesToDatabase(action: Pick<SqlShortcutAction, "databaseTypes">, databaseType?: DatabaseType): boolean {
const types = action.databaseTypes;
if (!types || types.length === 0) return true;
if (!databaseType) return false;
return types.includes(databaseType);
}
export function sqlShortcutDatabaseScopesOverlap(first?: readonly DatabaseType[], second?: readonly DatabaseType[]): boolean {
const firstAll = !first || first.length === 0;
const secondAll = !second || second.length === 0;
if (firstAll || secondAll) return true;
const secondSet = new Set(second);
return first.some((dbType) => secondSet.has(dbType));
}
export function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean {
if (!first || !second) return false;
if (canonicalShortcutKey(first) === canonicalShortcutKey(second)) return true;
return formatShortcut(first, platform).toLowerCase() === formatShortcut(second, platform).toLowerCase();
}
export function uniqueSqlShortcutBindings(actions: readonly SqlShortcutAction[], platform = globalThis.navigator?.platform || ""): string[] {
const bindings: string[] = [];
for (const action of enabledSqlShortcutActions(actions)) {
const shortcut = action.shortcut.trim();
if (bindings.some((existing) => shortcutsUseSameKeys(existing, shortcut, platform))) continue;
bindings.push(shortcut);
}
return bindings;
}
/**
* Prefer a scoped action over an all-databases action when both match the same key.
*/
export function resolveSqlShortcutForDatabase(actions: readonly SqlShortcutAction[], shortcut: string, databaseType?: DatabaseType, platform = globalThis.navigator?.platform || ""): SqlShortcutAction | undefined {
const matches = enabledSqlShortcutActions(actions).filter((action) => shortcutsUseSameKeys(action.shortcut, shortcut, platform) && sqlShortcutAppliesToDatabase(action, databaseType));
if (matches.length === 0) return undefined;
matches.sort((a, b) => (b.databaseTypes?.length ?? 0) - (a.databaseTypes?.length ?? 0));
return matches[0];
}
export function buildSqlShortcutExecutionSql(action: SqlShortcutAction, selectedTable: string, databaseType?: DatabaseType): string {
const table = selectedTable.trim();
if (normalizeSqlShortcutKind(action.kind) === "select-limit") {
return buildSelectStarWithLimitSql(table, normalizeSqlShortcutLimit(action.limit), databaseType);
}
return resolveSqlShortcutTemplate(resolveSqlShortcutBody(action, databaseType), table);
}
export function sqlShortcutDisplaySql(action: SqlShortcutAction, databaseType?: DatabaseType): string {
if (normalizeSqlShortcutKind(action.kind) === "select-limit") {
return buildSelectStarWithLimitSql(SQL_SHORTCUT_TABLE_TOKEN, normalizeSqlShortcutLimit(action.limit), databaseType);
}
return resolveSqlShortcutBody(action, databaseType);
}
export function findSqlShortcutConflicts(actions: readonly SqlShortcutAction[], fixedShortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): string[] {
const conflicts = new Set<string>();
const editorFixedShortcuts = SHORTCUT_DEFINITIONS.filter((item) => item.scope === "editor" || item.scope === "global");
@@ -25,7 +166,7 @@ export function findSqlShortcutConflicts(actions: readonly SqlShortcutAction[],
for (const action of actions) {
if (action.enabled === false || !action.shortcut.trim()) continue;
const duplicate = actions.find((other) => other.id !== action.id && other.enabled !== false && other.shortcut.trim() && shortcutsUseSameKeys(other.shortcut, action.shortcut, platform));
const duplicate = actions.find((other) => other.id !== action.id && other.enabled !== false && other.shortcut.trim() && shortcutsUseSameKeys(other.shortcut, action.shortcut, platform) && sqlShortcutDatabaseScopesOverlap(action.databaseTypes, other.databaseTypes));
if (duplicate) {
conflicts.add(action.id);
conflicts.add(duplicate.id);
@@ -0,0 +1,123 @@
import type { DatabaseType } from "@/types/database";
/** In-dialog state for custom SQL shortcut bodies (fallback + per-DB overrides). */
export type SqlShortcutFormBodies = {
/** Default fallback template persisted as payload.sql */
sql: string;
/** Textarea content for the current editing context */
body: string;
databaseTypes: DatabaseType[];
sqlByDatabaseType: Partial<Record<DatabaseType, string>>;
editingDatabaseType: DatabaseType | "";
};
/** Stage the current textarea into the override map (scoped) or default sql (all-databases). */
export function flushSqlShortcutEditingBody(form: SqlShortcutFormBodies): SqlShortcutFormBodies {
if (!form.editingDatabaseType) {
return { ...form, sql: form.body, sqlByDatabaseType: { ...form.sqlByDatabaseType } };
}
return {
...form,
sqlByDatabaseType: { ...form.sqlByDatabaseType, [form.editingDatabaseType]: form.body },
};
}
/** Flush current body, then load textarea for another selected database. */
export function switchSqlShortcutEditingDatabaseType(form: SqlShortcutFormBodies, next: DatabaseType): SqlShortcutFormBodies {
const flushed = flushSqlShortcutEditingBody(form);
return {
...flushed,
editingDatabaseType: next,
body: flushed.sqlByDatabaseType[next] ?? flushed.sql,
};
}
export function toggleSqlShortcutFormDatabaseType(form: SqlShortcutFormBodies, dbType: DatabaseType): SqlShortcutFormBodies {
const flushed = flushSqlShortcutEditingBody(form);
const removing = flushed.databaseTypes.includes(dbType);
const nextTypes = removing ? flushed.databaseTypes.filter((item) => item !== dbType) : [...flushed.databaseTypes, dbType];
const sqlByDatabaseType = { ...flushed.sqlByDatabaseType };
if (removing) {
delete sqlByDatabaseType[dbType];
}
if (nextTypes.length === 0) {
// Last scoped DB removed → same as "all databases": keep visible text as the only sql.
const body = flushed.editingDatabaseType === dbType || !flushed.editingDatabaseType ? flushed.body : (sqlByDatabaseType[flushed.editingDatabaseType] ?? flushed.body);
return {
...flushed,
databaseTypes: [],
sqlByDatabaseType: {},
editingDatabaseType: "",
sql: body,
body,
};
}
let editingDatabaseType = flushed.editingDatabaseType;
if (!editingDatabaseType || !nextTypes.includes(editingDatabaseType)) {
editingDatabaseType = nextTypes[0]!;
}
return {
...flushed,
databaseTypes: nextTypes,
sqlByDatabaseType,
editingDatabaseType,
body: sqlByDatabaseType[editingDatabaseType] ?? flushed.sql,
sql: flushed.sql,
};
}
/** Switch back to all-databases: wipe overrides, keep currently visible text as sql. */
export function clearSqlShortcutFormDatabaseTypes(form: SqlShortcutFormBodies): SqlShortcutFormBodies {
const flushed = flushSqlShortcutEditingBody(form);
const body = flushed.editingDatabaseType && flushed.sqlByDatabaseType[flushed.editingDatabaseType] != null ? flushed.sqlByDatabaseType[flushed.editingDatabaseType]! : flushed.body;
return {
...flushed,
databaseTypes: [],
sqlByDatabaseType: {},
editingDatabaseType: "",
sql: body,
body,
};
}
/** Copy current textarea to every selected DB override and the default sql. */
export function applySqlShortcutBodyToAllSelected(form: SqlShortcutFormBodies): SqlShortcutFormBodies {
if (form.databaseTypes.length === 0) {
return { ...form, sql: form.body, sqlByDatabaseType: {} };
}
const sqlByDatabaseType: Partial<Record<DatabaseType, string>> = {};
for (const dbType of form.databaseTypes) {
sqlByDatabaseType[dbType] = form.body;
}
return {
...form,
sql: form.body,
sqlByDatabaseType,
};
}
/** Persistable bodies: default sql + overrides only for still-selected databases. */
export function buildSqlShortcutBodiesForSave(form: SqlShortcutFormBodies): {
sql: string;
databaseTypes?: DatabaseType[];
sqlByDatabaseType?: Partial<Record<DatabaseType, string>>;
} {
const flushed = flushSqlShortcutEditingBody(form);
if (flushed.databaseTypes.length === 0) {
return { sql: flushed.body };
}
const sqlByDatabaseType: Partial<Record<DatabaseType, string>> = {};
for (const dbType of flushed.databaseTypes) {
const override = flushed.sqlByDatabaseType[dbType];
if (override != null) sqlByDatabaseType[dbType] = override;
}
return {
sql: flushed.sql,
databaseTypes: [...flushed.databaseTypes],
...(Object.keys(sqlByDatabaseType).length > 0 ? { sqlByDatabaseType } : {}),
};
}
@@ -0,0 +1,35 @@
import { extractQualifiedIdentifierAt, isSqlKeyword } from "@/lib/sql/sqlNavigation";
export type SqlShortcutTableSelection = {
from: number;
to: number;
empty: boolean;
/** Cursor / selection head used when selection is empty. */
head: number;
};
function identifierAtCursor(doc: string, pos: number): { start: number; end: number } | null {
let located = extractQualifiedIdentifierAt(doc, pos);
// CodeMirror can report the boundary immediately after the token.
if (!located && pos > 0) located = extractQualifiedIdentifierAt(doc, pos - 1);
if (!located || located.parts.length === 0) return null;
const last = located.parts[located.parts.length - 1]!;
if (!last.quoted && isSqlKeyword(last.value)) return null;
return { start: located.start, end: located.end };
}
/**
* Resolve the `${table}` token for SQL quick actions:
* non-empty selection wins; otherwise use the qualified identifier under the cursor
* (preserving original quotes and dots from the document).
*/
export function resolveSqlShortcutTableToken(doc: string, selection: SqlShortcutTableSelection): string | null {
if (!selection.empty) {
const selected = doc.slice(selection.from, selection.to).trim();
return selected.length > 0 ? selected : null;
}
const located = identifierAtCursor(doc, selection.head);
if (!located) return null;
const token = doc.slice(located.start, located.end).trim();
return token.length > 0 ? token : null;
}
@@ -1,7 +1,6 @@
import { buildSelectSnippetBody, DEFAULT_SELECT_ROW_LIMIT } from "@/lib/sql/sqlDialectSelectLimit";
import type { DatabaseType, SqlSnippet } from "@/types/database";
const DEFAULT_SELECT_ROW_LIMIT = 100;
/**
* Built-in SQL snippets shared by database types.
*
@@ -129,48 +128,9 @@ interface BuiltinSqlSnippetRule {
buildBody: BuiltinSqlSnippetBodyBuilder;
}
type SelectSnippetLimitStyle = "limit" | "top" | "first" | "fetch-first" | "rows" | "rownum" | "unbounded";
const SELECT_SNIPPET_LIMIT_STYLE_BY_DATABASE: Partial<Record<DatabaseType, SelectSnippetLimitStyle>> = {
oracle: "rownum",
"oceanbase-oracle": "rownum",
oscar: "rownum",
dameng: "rownum",
db2: "fetch-first",
sqlserver: "top",
access: "top",
iris: "top",
teradata: "top",
informix: "first",
firebird: "rows",
// An unknown JDBC driver may expose any SQL dialect. Known JDBC profiles are
// converted to their effective database type before reaching the editor.
jdbc: "unbounded",
};
const PARENTHESIZED_ADD_COLUMN_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "yashandb", "xugu", "dameng", "iris", "informix"]);
const ADD_COLUMN_WITHOUT_COLUMN_KEYWORD_DATABASES = new Set<DatabaseType>(["sqlserver", "kingbase", "cassandra", "teradata"]);
function buildSelectSnippetBody(databaseType?: DatabaseType): string {
const style = databaseType ? (SELECT_SNIPPET_LIMIT_STYLE_BY_DATABASE[databaseType] ?? "limit") : "limit";
switch (style) {
case "top":
return `SELECT TOP ${DEFAULT_SELECT_ROW_LIMIT} *\nFROM table;`;
case "first":
return `SELECT FIRST ${DEFAULT_SELECT_ROW_LIMIT} *\nFROM table;`;
case "fetch-first":
return `SELECT *\nFROM table\nFETCH FIRST ${DEFAULT_SELECT_ROW_LIMIT} ROWS ONLY;`;
case "rows":
return `SELECT *\nFROM table\nROWS ${DEFAULT_SELECT_ROW_LIMIT};`;
case "rownum":
return `SELECT *\nFROM table\nWHERE ROWNUM <= ${DEFAULT_SELECT_ROW_LIMIT};`;
case "unbounded":
return "SELECT *\nFROM table;";
case "limit":
return `SELECT *\nFROM table\nLIMIT ${DEFAULT_SELECT_ROW_LIMIT};`;
}
}
function buildUpdateSnippetBody(databaseType?: DatabaseType): string {
if (databaseType === "clickhouse") {
return "ALTER TABLE table\nUPDATE column = value\nWHERE condition;";