Files
dbx/apps/desktop/src/lib/sql/sqlInListPaste.ts
T

200 lines
5.8 KiB
TypeScript

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 SIMPLE_SLASH_LIST_VALUE_RE = /^[A-Za-z0-9_.:-]+$/;
export function buildSqlInConditionFromPasteSource(source: string, keywordCase: "preserve" | "upper" | "lower" = "upper"): 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);
const inKeyword = keywordCase === "lower" ? "in" : "IN";
return {
ok: true,
sql: `${inKeyword} (${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";
return `'${token.value.replace(/'/g, "''")}'`;
}