200 lines
5.8 KiB
TypeScript
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, "''")}'`;
|
|
}
|