feat(editor): add ExPaste paste-as-IN-condition helper (#2746)

* feat: add ExPaste SQL IN condition helper

* fix: restrict ExPaste to SQL-like editors

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-07-07 12:33:49 +08:00
1 parent 29495a7220
commit 705bacba04
17 files changed
+491 -8

No files matched your search

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation";
import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing";
import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
describe("supportsTransaction", () => {
it("returns true for supported database types", () => {
@@ -30,6 +30,39 @@ describe("supportsTransaction", () => {
});
});
describe("supportsSqlInListPaste", () => {
it("allows generic and SQL-like editors", () => {
expect(supportsSqlInListPaste(undefined)).toBe(true);
expect(supportsSqlInListPaste("mysql")).toBe(true);
expect(supportsSqlInListPaste("postgres")).toBe(true);
expect(supportsSqlInListPaste("oracle")).toBe(true);
expect(supportsSqlInListPaste("sqlserver")).toBe(true);
expect(supportsSqlInListPaste("sqlite")).toBe(true);
expect(supportsSqlInListPaste("cassandra")).toBe(true);
expect(supportsSqlInListPaste("tdengine")).toBe(true);
expect(supportsSqlInListPaste("iotdb")).toBe(true);
expect(supportsSqlInListPaste("jdbc")).toBe(true);
});
it("hides SQL IN list paste in non-SQL editors", () => {
expect(supportsSqlInListPaste("redis")).toBe(false);
expect(supportsSqlInListPaste("mongodb")).toBe(false);
expect(supportsSqlInListPaste("elasticsearch")).toBe(false);
expect(supportsSqlInListPaste("qdrant")).toBe(false);
expect(supportsSqlInListPaste("milvus")).toBe(false);
expect(supportsSqlInListPaste("weaviate")).toBe(false);
expect(supportsSqlInListPaste("chromadb")).toBe(false);
expect(supportsSqlInListPaste("etcd")).toBe(false);
expect(supportsSqlInListPaste("zookeeper")).toBe(false);
expect(supportsSqlInListPaste("mq")).toBe(false);
expect(supportsSqlInListPaste("nacos")).toBe(false);
});
it("excludes Neo4j because Cypher uses list syntax instead of SQL IN tuples", () => {
expect(supportsSqlInListPaste("neo4j")).toBe(false);
});
});
describe("database property editing", () => {
it("allows MySQL-compatible charset and collation edits on database nodes", () => {
expect(editableDatabasePropertyGroups({ db_type: "mysql" }, { type: "database", database: "app" })).toEqual(["charsetCollation"]);
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
describe("shortcutRegistry editor actions", () => {
const formatterEditorActionIds: ShortcutActionId[] = ["formatSql", "indentMore", "indentLess", "duplicateLine", "deleteLine", "moveLineUp", "moveLineDown", "copyLineUp", "copyLineDown", "undo", "redo", "selectAll", "uppercaseSelection", "lowercaseSelection"];
const formatterEditorActionIds: ShortcutActionId[] = ["formatSql", "indentMore", "indentLess", "duplicateLine", "deleteLine", "moveLineUp", "moveLineDown", "copyLineUp", "copyLineDown", "undo", "redo", "selectAll", "uppercaseSelection", "lowercaseSelection", "exPasteSqlInCondition"];
const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"];
it("registers formatter editor shortcuts in the generic editor scope", () => {
@@ -32,6 +32,7 @@ describe("shortcutRegistry editor actions", () => {
expect(shortcuts.selectAll).toBe("Mod+A");
expect(shortcuts.uppercaseSelection).toBe("Shift+Alt+U");
expect(shortcuts.lowercaseSelection).toBe("Shift+Alt+L");
expect(shortcuts.exPasteSqlInCondition).toBe("");
});
it("detects conflicts between formatter editor shortcuts and other editor shortcuts", () => {
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition, SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH, SQL_IN_LIST_PASTE_MAX_VALUES } from "@/lib/sql/sqlInListPaste";
describe("sqlInListPaste", () => {
it("builds an IN condition from newline values", () => {
expect(buildSqlInConditionFromPasteSource("A\nB\nC")).toEqual({
ok: true,
sql: "IN ('A', 'B', 'C')",
valueCount: 3,
});
});
it("splits comma, tab, and newline separated clipboard content", () => {
expect(buildSqlInConditionFromPasteSource("A\tB\nC,D")).toEqual({
ok: true,
sql: "IN ('A', 'B', 'C', 'D')",
valueCount: 4,
});
});
it("splits simple slash-separated value lists", () => {
expect(buildSqlInConditionFromPasteSource("1/2/3")).toEqual({
ok: true,
sql: "IN (1, 2, 3)",
valueCount: 3,
});
expect(buildSqlInConditionFromPasteSource("A/B/C")).toEqual({
ok: true,
sql: "IN ('A', 'B', 'C')",
valueCount: 3,
});
});
it("does not treat common dates, URLs, or absolute paths as value lists", () => {
expect(buildSqlInConditionFromPasteSource("2026/07/07")).toEqual({ ok: false, reason: "not-list" });
expect(buildSqlInConditionFromPasteSource("http://example.com/a/b")).toEqual({ ok: false, reason: "not-list" });
expect(buildSqlInConditionFromPasteSource("/Users/staff/dbx")).toEqual({ ok: false, reason: "not-list" });
});
it("preserves numeric and NULL literals while quoting strings", () => {
expect(buildSqlInConditionFromPasteSource("1\n-2.5\n001\nnull\nA1")).toEqual({
ok: true,
sql: "IN (1, -2.5, '001', NULL, 'A1')",
valueCount: 5,
});
});
it("strips existing SQL list wrappers and escapes single quotes once", () => {
expect(buildSqlInConditionFromPasteSource("('O''Reilly', \"Bob\")")).toEqual({
ok: true,
sql: "IN ('O''Reilly', 'Bob')",
valueCount: 2,
});
expect(buildSqlInConditionFromPasteSource("IN ('A', 'B')")).toEqual({
ok: true,
sql: "IN ('A', 'B')",
valueCount: 2,
});
expect(buildSqlInConditionFromPasteSource("('A')")).toEqual({
ok: true,
sql: "IN ('A')",
valueCount: 1,
});
});
it("does not split delimiters inside quoted values", () => {
expect(buildSqlInConditionFromPasteSource("'A,B'\n'C\tD'")).toEqual({
ok: true,
sql: "IN ('A,B', 'C\tD')",
valueCount: 2,
});
});
it("treats apostrophes inside unquoted values as text", () => {
expect(buildSqlInConditionFromPasteSource("O'Reilly\nBob")).toEqual({
ok: true,
sql: "IN ('O''Reilly', 'Bob')",
valueCount: 2,
});
});
it("returns empty for blank or delimiter-only content", () => {
expect(buildSqlInConditionFromPasteSource(" \n,\t ")).toEqual({ ok: false, reason: "empty" });
});
it("does not process unrelated single text values", () => {
expect(buildSqlInConditionFromPasteSource("paste some random text here")).toEqual({ ok: false, reason: "not-list" });
expect(buildSqlInConditionFromPasteSource("O'Reilly")).toEqual({ ok: false, reason: "not-list" });
});
it("guards very large paste input and value count", () => {
expect(buildSqlInConditionFromPasteSource("x".repeat(SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH + 1))).toEqual({
ok: false,
reason: "too-large",
limit: SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH,
});
expect(buildSqlInConditionFromPasteSource(Array.from({ length: SQL_IN_LIST_PASTE_MAX_VALUES + 1 }, (_, index) => String(index)).join("\n"))).toEqual({
ok: false,
reason: "too-many-values",
limit: SQL_IN_LIST_PASTE_MAX_VALUES,
});
});
it("avoids duplicating IN when the cursor is already after IN or NOT IN", () => {
expect(insertTextForSqlInCondition("IN ('A')", "where id in ")).toBe("('A')");
expect(insertTextForSqlInCondition("IN ('A')", "where id not in")).toBe("('A')");
expect(insertTextForSqlInCondition("IN ('A')", "where id")).toBe(" IN ('A')");
expect(insertTextForSqlInCondition("IN ('A')", "where id = ")).toBe("IN ('A')");
});
});
@@ -38,6 +38,13 @@ export function supportsSqlFileExecution(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "sqlFileExecution");
}
const NON_SQL_IN_LIST_PASTE_TYPES = new Set<DatabaseType>(["neo4j"]);
export function supportsSqlInListPaste(dbType?: DatabaseType): boolean {
if (!dbType) return true;
return supportsSqlFileExecution(dbType) && !NON_SQL_IN_LIST_PASTE_TYPES.has(dbType);
}
export function supportsSchemaDiagram(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "diagram");
}
@@ -18,6 +18,7 @@ export type ShortcutActionId =
| "selectAll"
| "uppercaseSelection"
| "lowercaseSelection"
| "exPasteSqlInCondition"
| "copyCurrentRow"
| "deleteCurrentRow"
| "newQuery"
@@ -163,6 +164,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Shift+Alt+L",
},
{
id: "exPasteSqlInCondition",
labelKey: "settings.shortcutExPasteSqlInCondition",
scope: "editor",
defaultShortcut: "",
},
{
id: "copyCurrentRow",
labelKey: "settings.shortcutCopyCurrentRow",
+200
View File
@@ -0,0 +1,200 @@
export const SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH = 1024 * 1024;
export const SQL_IN_LIST_PASTE_MAX_VALUES = 10_000;
export type SqlInListPasteError = "empty" | "not-list" | "too-large" | "too-many-values";
export type SqlInListPasteResult =
| {
ok: true;
sql: string;
valueCount: number;
}
| {
ok: false;
reason: SqlInListPasteError;
limit?: number;
};
interface ParsedPasteValue {
value: string;
quoted: boolean;
}
interface ParsedPasteValues {
values: ParsedPasteValue[];
explicitList: boolean;
}
const SQL_NUMBER_LITERAL_RE = /^[+-]?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
const SIMPLE_SLASH_LIST_VALUE_RE = /^[A-Za-z0-9_.:-]+$/;
export function buildSqlInConditionFromPasteSource(source: string): SqlInListPasteResult {
if (source.length > SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH) {
return { ok: false, reason: "too-large", limit: SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH };
}
const parsed = parsePastedValues(source);
const values = parsed.values;
if (values.length === 0) return { ok: false, reason: "empty" };
if (values.length === 1 && !parsed.explicitList) return { ok: false, reason: "not-list" };
if (values.length > SQL_IN_LIST_PASTE_MAX_VALUES) {
return { ok: false, reason: "too-many-values", limit: SQL_IN_LIST_PASTE_MAX_VALUES };
}
const literals = values.map(formatSqlLiteral);
return {
ok: true,
sql: `IN (${literals.join(", ")})`,
valueCount: values.length,
};
}
export function sqlInConditionNeedsListOnly(prefix: string): boolean {
return /\b(?:not\s+)?in\s*$/i.test(prefix);
}
export function insertTextForSqlInCondition(condition: string, prefix: string): string {
if (sqlInConditionNeedsListOnly(prefix)) return condition.replace(/^IN\s+/i, "");
if (!prefix || /\s$|\($/.test(prefix)) return condition;
return ` ${condition}`;
}
function parsePastedValues(source: string): ParsedPasteValues {
const sourceText = source.replace(/\r\n?/g, "\n").trim();
const withoutInKeyword = stripLeadingInKeyword(sourceText);
const explicitSource = withoutInKeyword.trim();
const explicitList = explicitSource.startsWith("(") && explicitSource.endsWith(")") && hasSingleWrappingParentheses(explicitSource);
const trimmed = stripOuterParentheses(withoutInKeyword);
if (!trimmed) return { values: [], explicitList };
return {
values: splitPasteTokens(trimmed)
.map(normalizePastedToken)
.filter((value): value is ParsedPasteValue => !!value),
explicitList,
};
}
function stripLeadingInKeyword(value: string): string {
const match = /^(?:not\s+)?in\b\s*([\s\S]*)$/i.exec(value.trim());
return match ? match[1].trim() : value;
}
function splitPasteTokens(source: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
const slashIsDelimiter = shouldSplitSlashSeparatedValues(source);
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (quote) {
current += char;
if (char === quote) {
if (next === quote) {
current += next;
index += 1;
} else {
quote = null;
}
}
continue;
}
if ((char === "'" || char === '"') && current.trim().length === 0) {
quote = char;
current += char;
continue;
}
if (char === "," || char === "\n" || char === "\t" || (slashIsDelimiter && char === "/")) {
tokens.push(current);
current = "";
continue;
}
current += char;
}
tokens.push(current);
return tokens;
}
function shouldSplitSlashSeparatedValues(source: string): boolean {
const trimmed = source.trim();
if (!trimmed.includes("/") || /[,\n\t]/.test(trimmed)) return false;
if (trimmed.includes("'") || trimmed.includes('"')) return false;
if (trimmed.startsWith("/") || /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return false;
if (/^\d{4}\/\d{1,2}\/\d{1,2}(?:\D|$)/.test(trimmed) || /^\d{1,2}\/\d{1,2}\/\d{4}(?:\D|$)/.test(trimmed)) return false;
const parts = trimmed.split("/").map((part) => part.trim());
if (parts.length < 2 || parts.some((part) => !part)) return false;
return parts.every((part) => SIMPLE_SLASH_LIST_VALUE_RE.test(part));
}
function normalizePastedToken(token: string): ParsedPasteValue | null {
let value = stripOuterParentheses(
token
.trim()
.replace(/[;,]+$/g, "")
.trim(),
);
if (!value) return null;
const first = value[0];
const last = value[value.length - 1];
const quoted = value.length >= 2 && ((first === "'" && last === "'") || (first === '"' && last === '"'));
if (quoted) {
value = value.slice(1, -1);
value = first === "'" ? value.replace(/''/g, "'") : value.replace(/""/g, '"');
}
value = value.trim();
if (!value) return null;
return { value, quoted };
}
function stripOuterParentheses(value: string): string {
let next = value.trim();
while (next.startsWith("(") && next.endsWith(")") && hasSingleWrappingParentheses(next)) {
next = next.slice(1, -1).trim();
}
return next;
}
function hasSingleWrappingParentheses(value: string): boolean {
let depth = 0;
let quote: "'" | '"' | null = null;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const next = value[index + 1];
if (quote) {
if (char === quote) {
if (next === quote) index += 1;
else quote = null;
}
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (depth === 0 && index < value.length - 1) return false;
}
return depth === 0;
}
function formatSqlLiteral(token: ParsedPasteValue): string {
if (!token.quoted && /^null$/i.test(token.value)) return "NULL";
if (!token.quoted && SQL_NUMBER_LITERAL_RE.test(token.value)) return token.value;
return `'${token.value.replace(/'/g, "''")}'`;
}