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:
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",
|
||||
|
||||
@@ -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, "''")}'`;
|
||||
}
|
||||
Reference in new issue
Block a user