fix(formatter): format Neo4j Cypher queries

Closes #10633
This commit is contained in:
t8y2
2026-09-29 09:32:15 +00:00
parent 7a24227e56
commit faa7040439
3 changed files with 621 additions and 3 deletions
+62 -1
View File
@@ -1,7 +1,68 @@
import { describe, expect, it } from "vitest";
import { canFormatSqlForDatabaseType, formatSqlForDisplay, formatSqlForEditing, formatSqlText, MAX_SQL_FORMAT_CHARS, sqlFormatDialectForDbType, UnsupportedStructuredInputError } from "@/lib/sql/sqlFormatter";
import { canFormatSqlForDatabaseType, compressSqlText, formatSqlForDisplay, formatSqlForEditing, formatSqlText, MAX_SQL_FORMAT_CHARS, sqlFormatDialectForDbType, UnsupportedStructuredInputError } from "@/lib/sql/sqlFormatter";
import { extractSqlParameters } from "@/lib/sql/sqlParameters";
describe("Neo4j Cypher formatting", () => {
const reproduction = `MATCH (a:LibraryDomainOrganization) WHERE a.id= $id CALL apoc.path.expandConfig(a, {uniqueness:'RELATIONSHIP_GLOBAL', minLevel:0, maxLevel:-1, relationshipFilter:'HasLibrary>|HasLibraryParameter>' }) YIELD path UNWIND relationships(path) as r WITH DISTINCT r WITH r, startNode(r) as s, endNode(r) as e OPTIONAL MATCH (s)-[:panda_data_master_version_rel]-(so) OPTIONAL MATCH (e)-[:panda_data_master_version_rel]-(eo) WITH r, CASE WHEN s.classType = 'COMMON' OR s.classType = 'INTERFACE' THEN s WHEN type(r) = 'panda_data_master_version_rel' THEN null ELSE so END as sm OPTIONAL MATCH (sm)-[permission:panda_dataPermission]->(target) WHERE target:panda_member AND target.id IN $memberIds AND permission.permissionType IN ['OWNER','ACTIVE','PASSIVE'] RETURN type(r) as relationType, sm, [s, e] as endpoints`;
it("uses the Cypher path for Neo4j and formats the reported query shape", async () => {
expect(canFormatSqlForDatabaseType("neo4j")).toBe(true);
expect(sqlFormatDialectForDbType("neo4j")).toBe("cypher");
const formatted = await formatSqlForEditing(reproduction, sqlFormatDialectForDbType("neo4j"));
expect(formatted).not.toBe(reproduction);
expect(formatted).toContain("MATCH (a:LibraryDomainOrganization)");
expect(formatted).toContain("CALL apoc.path.expandConfig(a, {uniqueness: 'RELATIONSHIP_GLOBAL'");
expect(formatted).toContain("relationshipFilter: 'HasLibrary>|HasLibraryParameter>'");
expect(formatted).toContain("OPTIONAL MATCH (s)-[:panda_data_master_version_rel]-(so)");
expect(formatted).toContain("target:panda_member");
expect(formatted).toContain("IN ['OWNER', 'ACTIVE', 'PASSIVE']");
expect(formatted).toContain("$memberIds");
expect(formatted).toContain("RETURN type(r) AS relationType");
expect(formatted.split("\n").length).toBeGreaterThan(8);
});
it("keeps comments, strings, labels, map keys, parameters, and nested values opaque", async () => {
const source = `// MATCH and RETURN in this comment\nMATCH (n:CaseSensitive {match: 'RETURN WHERE', values: ['CALL', '$id']}) WHERE n.note = "WITH RETURN" RETURN n`;
const formatted = await formatSqlForEditing(source, "cypher");
expect(formatted).toContain("// MATCH and RETURN in this comment");
expect(formatted).toContain("(n:CaseSensitive {match: 'RETURN WHERE', values: ['CALL', '$id']})");
expect(formatted).toContain('n.note = "WITH RETURN"');
});
it("keeps block comments opaque", async () => {
const source = "/* RETURN MATCH {not: 'code'} */ MATCH (n:Person) RETURN n";
const formatted = await formatSqlForEditing(source, "cypher");
expect(formatted).toContain("/* RETURN MATCH {not: 'code'} */");
expect(formatted).toContain("MATCH (n:Person)");
});
it("formats a selected Cypher fragment with the same entry point", async () => {
const selected = "OPTIONAL MATCH (n:Person)-[:KNOWS]->(m) RETURN n, m";
const formatted = await formatSqlForEditing(selected, "cypher");
expect(formatted).toContain("OPTIONAL MATCH (n:Person)-[:KNOWS]->(m)");
expect(formatted).toContain("RETURN n,\n m");
});
it("does not let a Cypher line comment swallow compressed query text", () => {
expect(compressSqlText("// keep the note\nMATCH (n:Person) RETURN n", "cypher")).toBe("MATCH (n:Person) RETURN n");
});
it("returns unsupported or incomplete Cypher unchanged", async () => {
const unsupported = "MATCH (n) RETURN n ≠ 1";
const incomplete = "MATCH (n RETURN n";
const incompleteParameter = "MATCH (n) RETURN ${id";
await expect(formatSqlForEditing(unsupported, "cypher")).resolves.toBe(unsupported);
await expect(formatSqlForEditing(incomplete, "cypher")).resolves.toBe(incomplete);
await expect(formatSqlForEditing(incompleteParameter, "cypher")).resolves.toBe(incompleteParameter);
});
});
describe("sqlFormatter", () => {
it("disables SQL formatting for Redis and VictoriaMetrics queries", () => {
expect(canFormatSqlForDatabaseType("redis")).toBe(false);
+551
View File
@@ -0,0 +1,551 @@
import type { SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
type CypherTokenKind = "word" | "number" | "string" | "quotedIdentifier" | "comment" | "operator" | "punctuation" | "parameter";
interface CypherToken {
kind: CypherTokenKind;
text: string;
lineBreakBefore: boolean;
blankLineBefore: boolean;
}
interface CypherTokenizeResult {
tokens: CypherToken[];
trailingLineBreak: boolean;
}
interface CypherClause {
words: string[];
end: number;
name: string;
}
const CLAUSE_WORDS = new Set(["CALL", "CREATE", "DELETE", "DETACH", "DROP", "ENABLE", "FOREACH", "GRANT", "LIMIT", "LOAD", "MATCH", "MERGE", "OPTIONAL", "REMOVE", "RENAME", "RETURN", "REVOKE", "SET", "SHOW", "SKIP", "START", "TERMINATE", "UNION", "UNWIND", "USE", "WHERE", "WITH", "YIELD"]);
const MULTIWORD_CLAUSES: string[][] = [
["DETACH", "DELETE"],
["LOAD", "CSV"],
["ON", "CREATE", "SET"],
["ON", "MATCH", "SET"],
["OPTIONAL", "MATCH"],
["ORDER", "BY"],
["START", "WITH"],
["UNION", "ALL"],
];
const FORMAT_KEYWORDS = new Set([
"ALL",
"AND",
"AS",
"ASC",
"BY",
"CALL",
"CASE",
"CREATE",
"DELETE",
"DESC",
"DETACH",
"DISTINCT",
"ELSE",
"END",
"FOREACH",
"IN",
"IS",
"LIMIT",
"LOAD",
"MATCH",
"MERGE",
"NOT",
"NULL",
"ON",
"OPTIONAL",
"OR",
"ORDER",
"REMOVE",
"RETURN",
"SET",
"SKIP",
"START",
"THEN",
"TRUE",
"UNION",
"UNWIND",
"WHEN",
"WHERE",
"WITH",
"XOR",
"YIELD",
"FALSE",
]);
const LOGICAL_WORDS = new Set(["AND", "OR", "XOR"]);
const PROJECTION_CLAUSES = new Set(["RETURN", "WITH", "YIELD"]);
const CLAUSE_BOUNDARY_WORDS = CLAUSE_WORDS;
const MULTI_CHAR_OPERATORS = ["<--", "-->", "->", "<-", "..", "=~", "<=", ">=", "<>", "!=", "+=", "-=", "*=", "/=", "||", "::"];
const SINGLE_CHAR_OPERATORS = new Set(["+", "-", "/", "%", "=", "<", ">", "!", "?", "|", "&", "^", "~", "*"]);
const PUNCTUATION = new Set(["(", ")", "[", "]", "{", "}", ",", ";", ".", ":"]);
function isWordStart(char: string | undefined): boolean {
return char !== undefined && /[\p{L}_]/u.test(char);
}
function isWordPart(char: string | undefined): boolean {
return char !== undefined && /[\p{L}\p{N}_]/u.test(char);
}
function isWhitespace(char: string | undefined): boolean {
return char !== undefined && /\s/u.test(char);
}
function codePointAt(source: string, index: number): string | undefined {
const point = source.codePointAt(index);
return point === undefined ? undefined : String.fromCodePoint(point);
}
function advanceCodePoint(source: string, index: number): number {
const point = source.codePointAt(index);
return point === undefined ? index + 1 : index + (point > 0xffff ? 2 : 1);
}
function scanQuoted(source: string, start: number, quote: "'" | '"' | "`"): number | null {
let index = start + 1;
while (index < source.length) {
if (source[index] === "\\" && quote !== "`" && index + 1 < source.length) {
index += 2;
continue;
}
if (source[index] === quote) {
// Cypher escapes backticks and SQL-style quote literals by doubling them.
if (source[index + 1] === quote) {
index += 2;
continue;
}
return index + 1;
}
index = advanceCodePoint(source, index);
}
return null;
}
function scanBlockComment(source: string, start: number): number | null {
let depth = 1;
let index = start + 2;
while (index < source.length) {
if (source.startsWith("/*", index)) {
depth += 1;
index += 2;
} else if (source.startsWith("*/", index)) {
depth -= 1;
index += 2;
if (depth === 0) return index;
} else {
index = advanceCodePoint(source, index);
}
}
return null;
}
function scanParameter(source: string, start: number): number | null {
if (source[start + 1] === "{") {
const end = source.indexOf("}", start + 2);
return end < 0 ? null : end + 1;
}
let index = start + 1;
while (isWordPart(codePointAt(source, index))) index = advanceCodePoint(source, index);
return index;
}
function scanNumber(source: string, start: number): number {
let index = start;
while (/[\p{N}_]/u.test(source[index] ?? "")) index += 1;
if (source[index] === "." && source[index + 1] !== ".") {
index += 1;
while (/[\p{N}_]/u.test(source[index] ?? "")) index += 1;
}
if (source[index] === "e" || source[index] === "E") {
let exponent = index + 1;
if (source[exponent] === "+" || source[exponent] === "-") exponent += 1;
const digits = exponent;
while (/[\p{N}_]/u.test(source[exponent] ?? "")) exponent += 1;
if (exponent > digits) index = exponent;
}
return index;
}
function tokenizeCypher(source: string): CypherTokenizeResult | null {
const tokens: CypherToken[] = [];
let index = 0;
let lineBreakBefore = false;
let blankLineBefore = false;
const push = (kind: CypherTokenKind, text: string) => {
tokens.push({ kind, text, lineBreakBefore, blankLineBefore });
lineBreakBefore = false;
blankLineBefore = false;
};
while (index < source.length) {
const char = source[index];
const next = source[index + 1];
if (isWhitespace(char)) {
let newlines = 0;
while (index < source.length && isWhitespace(source[index])) {
if (source[index] === "\n") newlines += 1;
if (source[index] === "\r" && source[index + 1] !== "\n") newlines += 1;
index += 1;
}
if (newlines > 0) {
lineBreakBefore = true;
blankLineBefore ||= newlines > 1;
}
continue;
}
if ((char === "/" && next === "/") || (char === "-" && next === "-")) {
const start = index;
index += 2;
while (index < source.length && source[index] !== "\n" && source[index] !== "\r") index += 1;
push("comment", source.slice(start, index));
continue;
}
if (char === "/" && next === "*") {
const end = scanBlockComment(source, index);
if (end === null) return null;
push("comment", source.slice(index, end));
index = end;
continue;
}
if (char === "'" || char === '"' || char === "`") {
const end = scanQuoted(source, index, char);
if (end === null) return null;
push(char === "`" ? "quotedIdentifier" : "string", source.slice(index, end));
index = end;
continue;
}
if (char === "$") {
const end = scanParameter(source, index);
if (end === null || end === index + 1) return null;
push("parameter", source.slice(index, end));
index = end;
continue;
}
const point = codePointAt(source, index);
if (isWordStart(point)) {
const start = index;
index = advanceCodePoint(source, index);
while (isWordPart(codePointAt(source, index))) index = advanceCodePoint(source, index);
push("word", source.slice(start, index));
continue;
}
if (/[\p{N}]/u.test(point ?? "")) {
const end = scanNumber(source, index);
push("number", source.slice(index, end));
index = end;
continue;
}
const operator = MULTI_CHAR_OPERATORS.find((candidate) => source.startsWith(candidate, index));
if (operator) {
push("operator", operator);
index += operator.length;
continue;
}
if (SINGLE_CHAR_OPERATORS.has(char)) {
push("operator", char);
index += 1;
continue;
}
if (PUNCTUATION.has(char)) {
push("punctuation", char);
index += 1;
continue;
}
// A formatter must never guess how an unknown character participates in a
// Cypher token. Returning null keeps full-width punctuation and future DSL
// extensions untouched instead of silently changing their meaning.
return null;
}
return { tokens, trailingLineBreak: lineBreakBefore };
}
function upper(token: CypherToken): string {
return token.kind === "word" ? token.text.toUpperCase() : token.text;
}
function clauseAt(tokens: readonly CypherToken[], index: number, depth: { paren: number; bracket: number; brace: number }): CypherClause | null {
if (depth.paren !== 0 || depth.bracket !== 0 || depth.brace !== 0) return null;
const token = tokens[index];
if (!token || token.kind !== "word") return null;
for (const words of MULTIWORD_CLAUSES) {
if (words.every((word, offset) => tokens[index + offset]?.kind === "word" && upper(tokens[index + offset]) === word)) {
return { words, end: index + words.length, name: words.join(" ") };
}
}
const word = upper(token);
if (!CLAUSE_BOUNDARY_WORDS.has(word)) return null;
return { words: [word], end: index + 1, name: word };
}
function caseText(text: string, setting: SqlFormatterSettings["keywordCase"]): string {
if (setting === "upper") return text.toUpperCase();
if (setting === "lower") return text.toLowerCase();
return text;
}
function isAtom(token: CypherToken | undefined): boolean {
return token !== undefined && (token.kind === "word" || token.kind === "number" || token.kind === "string" || token.kind === "quotedIdentifier" || token.kind === "parameter");
}
function isPathOperator(token: CypherToken | undefined, next: CypherToken | undefined, previous: CypherToken | undefined): boolean {
if (!token || token.kind !== "operator") return false;
if (token.text === "->" || token.text === "<-" || token.text === "-->" || token.text === "<--") return true;
return token.text === "-" && (next?.text === "(" || next?.text === "[" || previous?.text === ")" || previous?.text === "]" || previous?.text === "}");
}
function needsSpace(previous: CypherToken | undefined, current: CypherToken, next: CypherToken | undefined, mapColon: boolean): boolean {
if (!previous) return false;
const previousText = previous.text;
const currentText = current.text;
if ([")", "]", "}", ",", ";", ".", ":"].includes(currentText)) return false;
if (["(", "[", "."].includes(previousText)) return false;
if (previousText === ":") return mapColon;
if (previousText === ",") return true;
if (currentText === "(") return ["AND", "CASE", "ELSE", "MATCH", "NOT", "OPTIONAL", "OR", "THEN", "WHEN", "WHERE", "XOR"].includes(previousText.toUpperCase());
if (currentText === "[") {
if (isPathOperator(previous, current, undefined)) return false;
if (previous.kind === "word" && ["ALL", "ANY", "IN", "NONE", "SINGLE"].includes(previousText.toUpperCase())) return true;
return !isAtom(previous) && previousText !== ")" && previousText !== "]" && previousText !== "}";
}
if (currentText === "{") return previousText === "," || (previousText !== "(" && previousText !== "[" && previous.kind !== "operator");
if (currentText === "-") return !isPathOperator(current, next, previous);
if (current.kind === "operator") {
if (currentText === ".." || currentText === ":") return false;
if (isPathOperator(current, next, previous)) return false;
if (currentText === "!") return false;
return true;
}
if (previous.kind === "operator") {
if (previousText === ".." || previousText === ":" || previousText === "!") return false;
if (previousText === "-" && current.kind === "number") return false;
if (isPathOperator(previous, current, undefined)) return false;
return true;
}
if (previousText === "(") return false;
return isAtom(previous) || previousText === ")" || previousText === "]" || previousText === "}";
}
class CypherPrinter {
private readonly lines: string[] = [];
private current = "";
private lineIndent = 0;
private previous: CypherToken | undefined;
private depth = { paren: 0, bracket: 0, brace: 0 };
private clauseName = "";
private clauseDepth = { paren: 0, bracket: 0, brace: 0 };
private continuationIndent = 1;
private mapColon = false;
private recognizedClauses = 0;
constructor(private readonly settings: SqlFormatterSettings) {}
get hasRecognizedClause(): boolean {
return this.recognizedClauses > 0;
}
format(tokens: readonly CypherToken[], trailingLineBreak: boolean): string {
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const clause = clauseAt(tokens, index, this.depth);
if (clause) {
this.startClause(clause, tokens, index);
this.previous = tokens[clause.end - 1];
index = clause.end - 1;
continue;
}
if (token.kind === "comment") {
this.writeComment(token);
this.previous = token;
continue;
}
if (token.blankLineBefore && this.settings.preserveEmptyLines && this.current.trim()) this.newline(this.lineIndent, true);
const relativeTopLevel = this.isAtClauseTopLevel();
const word = token.kind === "word" ? upper(token) : "";
if (relativeTopLevel && LOGICAL_WORDS.has(word) && this.settings.logicalOperatorNewline !== "none") {
if (this.settings.logicalOperatorNewline === "before") this.newline(this.continuationIndent);
this.writeWord(token, word);
if (this.settings.logicalOperatorNewline === "after") this.newline(this.continuationIndent);
this.previous = token;
continue;
}
if (token.text === "," && relativeTopLevel && PROJECTION_CLAUSES.has(this.clauseName)) {
this.writeToken(token, index + 1 < tokens.length ? tokens[index + 1] : undefined);
this.newline(this.continuationIndent);
this.previous = token;
continue;
}
if (token.text === ";") {
this.writeToken(token, tokens[index + 1]);
this.newline(0);
this.clauseName = "";
this.previous = token;
continue;
}
if (token.kind === "word" && FORMAT_KEYWORDS.has(word) && !this.isSemanticIdentifier(tokens[index + 1])) {
this.writeWord(token, word);
} else {
this.writeToken(token, tokens[index + 1]);
}
this.updateDepth(token);
this.previous = token;
}
this.flush();
let output = this.lines.join("\n");
if (trailingLineBreak && output) output += "\n";
return output;
}
private startClause(clause: CypherClause, tokens: readonly CypherToken[], start: number): void {
if (this.current.trim()) this.newline(0);
this.clauseName = clause.name;
this.clauseDepth = { ...this.depth };
this.continuationIndent = this.lineIndent + 1;
this.recognizedClauses += 1;
clause.words.forEach((word, offset) => {
this.writeWord(tokens[start + offset], word, offset > 0);
});
}
private writeWord(token: CypherToken, word: string, forceSpace = false): void {
const text = this.settings.keywordCase === "preserve" ? token.text : caseText(word, this.settings.keywordCase);
this.writeToken({ ...token, text }, undefined, forceSpace);
}
private writeToken(token: CypherToken, next: CypherToken | undefined, forceSpace = false): void {
const atLineStart = this.current.trim().length === 0;
if (!atLineStart && (forceSpace || needsSpace(this.previous, token, next, this.mapColon))) this.current += " ";
this.current += token.text;
this.mapColon = token.text === ":" && this.depth.brace > 0;
}
private writeComment(token: CypherToken): void {
if (token.lineBreakBefore && this.current.trim()) this.newline(this.lineIndent);
if (this.current.trim() && !this.current.endsWith(" ")) this.current += " ";
this.current += token.text;
if (token.text.startsWith("//") || token.text.startsWith("--") || token.text.includes("\n")) this.newline(this.lineIndent);
}
private updateDepth(token: CypherToken): void {
if (token.text === "(") this.depth.paren += 1;
else if (token.text === ")") this.depth.paren -= 1;
else if (token.text === "[") this.depth.bracket += 1;
else if (token.text === "]") this.depth.bracket -= 1;
else if (token.text === "{") this.depth.brace += 1;
else if (token.text === "}") this.depth.brace -= 1;
}
private isAtClauseTopLevel(): boolean {
return this.depth.paren === this.clauseDepth.paren && this.depth.bracket === this.clauseDepth.bracket && this.depth.brace === this.clauseDepth.brace;
}
private isSemanticIdentifier(next: CypherToken | undefined): boolean {
return this.previous?.text === "." || this.previous?.text === ":" || (this.depth.brace > 0 && next?.text === ":") || next?.text === "(";
}
private newline(indent: number, forceBlank = false): void {
this.flush();
if (forceBlank && this.lines.length > 0 && this.lines[this.lines.length - 1] !== "") this.lines.push("");
this.lineIndent = indent;
this.current = this.indent(indent);
this.previous = undefined;
}
private indent(level: number): string {
return this.settings.useTabs ? "\t".repeat(level) : " ".repeat(level * this.settings.tabWidth);
}
private flush(): void {
const line = this.current.trimEnd();
if (line.trim()) this.lines.push(line);
this.current = this.indent(this.lineIndent);
}
}
/**
* Formats the supported Cypher query subset used by the Neo4j editor.
*
* This is intentionally a lexical formatter, not a Cypher parser. It only
* changes whitespace around recognized clauses and top-level projections. All
* literals, comments, parameters, labels, relationship patterns, maps, lists,
* and nested expressions remain opaque token text. Inputs outside that safe
* boundary are returned unchanged.
*/
export function formatCypherText(sql: string, settings: SqlFormatterSettings): string {
const tokenized = tokenizeCypher(sql);
if (!tokenized || tokenized.tokens.length === 0) return sql;
const stack: string[] = [];
for (const token of tokenized.tokens) {
if (token.text === "(" || token.text === "[" || token.text === "{") stack.push(token.text);
if (token.text === ")" || token.text === "]" || token.text === "}") {
const expected = token.text === ")" ? "(" : token.text === "]" ? "[" : "{";
if (stack.pop() !== expected) return sql;
}
}
if (stack.length > 0) return sql;
const printer = new CypherPrinter(settings);
const formatted = printer.format(tokenized.tokens, tokenized.trailingLineBreak);
return printer.hasRecognizedClause && formatted.trim() ? formatted : sql;
}
/** Compresses Cypher without treating `//` as ordinary SQL text. */
export function compressCypherText(sql: string): string {
const tokenized = tokenizeCypher(sql);
if (!tokenized || tokenized.tokens.length === 0) return sql;
const stack: string[] = [];
let output = "";
let previous: CypherToken | undefined;
let mapColon = false;
for (let index = 0; index < tokenized.tokens.length; index += 1) {
const token = tokenized.tokens[index];
if (token.text === "(" || token.text === "[" || token.text === "{") stack.push(token.text);
if (token.text === ")" || token.text === "]" || token.text === "}") {
const expected = token.text === ")" ? "(" : token.text === "]" ? "[" : "{";
if (stack.pop() !== expected) return sql;
}
if (token.kind === "comment") continue;
const next = tokenized.tokens[index + 1];
if (output && needsSpace(previous, token, next, mapColon)) output += " ";
output += token.text;
mapColon = token.text === ":" && stack.includes("{");
previous = token;
}
if (stack.length > 0 || !output.trim()) return sql;
return output.trim();
}
+8 -2
View File
@@ -1,8 +1,9 @@
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { formatSqlLayout, type SqlLayoutOptions } from "@/lib/sql/layout";
import { looksLikeXml } from "@/lib/sql/autoFormat";
import { compressCypherText, formatCypherText } from "@/lib/sql/cypherFormatter";
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "oracle" | "clickhouse" | "dameng" | "duckdb" | "generic";
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "oracle" | "clickhouse" | "dameng" | "duckdb" | "cypher" | "generic";
export const MAX_SQL_FORMAT_CHARS = 1_000_000;
@@ -87,6 +88,8 @@ export function sqlFormatDialectForDbType(dbType: string | null | undefined): Sq
return "dameng";
case "duckdb":
return "duckdb";
case "neo4j":
return "cypher";
default:
return "generic";
}
@@ -340,9 +343,11 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
throw new UnsupportedStructuredInputError("xml");
}
const normalizedSettings = normalizeSqlFormatterSettings(settings);
if (dialect === "cypher") return formatCypherText(sql, normalizedSettings);
const sqlFormatter = await import("sql-formatter");
const { format, formatDialect } = sqlFormatter;
const normalizedSettings = normalizeSqlFormatterSettings(settings);
const options = sqlFormatterOptions(normalizedSettings);
const language = formatterLanguage(dialect);
const emptyLineProtection = normalizedSettings.preserveEmptyLines ? protectEmptyLines(sql) : null;
@@ -733,6 +738,7 @@ export type SqlCompressDialect = SqlFormatDialect;
*/
export function compressSqlText(sql: string, dialect: SqlCompressDialect = "generic"): string {
if (!sql.trim()) return sql;
if (dialect === "cypher") return compressCypherText(sql);
const len = sql.length;
let out = "";