feat(sql-formatter): add setting to preserve blank lines
This commit is contained in:
16 files changed
+158
-7
No files matched your search
@@ -16,6 +16,30 @@ describe("sqlFormatter", () => {
|
||||
expect(canFormatSqlForDatabaseType("mysql")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves source empty lines only when configured", async () => {
|
||||
const sql = "-- tstt\n\nSELECT * FROM AUX_TABLE AS au LIMIT 100;";
|
||||
const consecutiveEmptyLines = "-- section\n\n\nSELECT 1;";
|
||||
const queriesWithEmptyLine = "SELECT 1;\n\nSELECT 2;";
|
||||
const queriesWithTwoEmptyLines = "SELECT 1;\n\n\nSELECT 2;";
|
||||
|
||||
const defaultFormatted = await formatSqlForEditing(sql, "generic");
|
||||
const preserved = await formatSqlForEditing(sql, "generic", { preserveEmptyLines: true });
|
||||
const preservedConsecutive = await formatSqlForEditing(consecutiveEmptyLines, "generic", { preserveEmptyLines: true });
|
||||
const preservedQueriesNoSpacing = await formatSqlForEditing(queriesWithEmptyLine, "generic", { preserveEmptyLines: true, linesBetweenQueries: 0 });
|
||||
const preservedQueries = await formatSqlForEditing(queriesWithEmptyLine, "generic", { preserveEmptyLines: true, linesBetweenQueries: 1 });
|
||||
const preservedQueriesWideSpacing = await formatSqlForEditing(queriesWithEmptyLine, "generic", { preserveEmptyLines: true, linesBetweenQueries: 2 });
|
||||
const preservedQueriesWithTwoEmptyLines = await formatSqlForEditing(queriesWithTwoEmptyLines, "generic", { preserveEmptyLines: true, linesBetweenQueries: 1 });
|
||||
|
||||
expect(defaultFormatted).toContain("-- tstt\nSELECT");
|
||||
expect(preserved).toContain("-- tstt\n\nSELECT");
|
||||
expect(preservedConsecutive).toContain("-- section\n\n\nSELECT");
|
||||
expect(preservedQueriesNoSpacing).toBe("SELECT\n 1;\n\nSELECT\n 2;");
|
||||
expect(preservedQueries).toBe("SELECT\n 1;\n\nSELECT\n 2;");
|
||||
expect(preservedQueriesWideSpacing).toBe("SELECT\n 1;\n\n\nSELECT\n 2;");
|
||||
expect(preservedQueriesWithTwoEmptyLines).toBe("SELECT\n 1;\n\n\nSELECT\n 2;");
|
||||
expect(preserved).not.toContain("__DBX_PRESERVE_EMPTY_LINE_");
|
||||
});
|
||||
|
||||
it("maps PostgreSQL-compatible database types to the postgres formatter dialect", () => {
|
||||
for (const dbType of ["postgres", "kwdb", "gaussdb", "opengauss", "questdb", "kingbase", "highgo", "vastbase", "redshift"]) {
|
||||
expect(sqlFormatDialectForDbType(dbType)).toBe("postgres");
|
||||
|
||||
@@ -63,4 +63,13 @@ describe("sqlFormatterConfig shortcut storage", () => {
|
||||
if (result.ok) expect(result.settings.fromClauseLayout).toBe("sameLine");
|
||||
expect(JSON.parse(serializeSqlFormatterConfig({ fromClauseLayout: "sameLine" })).options.fromClauseLayout).toBe("sameLine");
|
||||
});
|
||||
|
||||
it("defaults empty-line preservation off and serializes an explicit opt-in", () => {
|
||||
const defaultConfig = JSON.parse(serializeSqlFormatterConfig({}));
|
||||
const optIn = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { preserveEmptyLines: true } }));
|
||||
|
||||
expect(defaultConfig.options.preserveEmptyLines).toBe(false);
|
||||
expect(optIn).toEqual(expect.objectContaining({ ok: true }));
|
||||
if (optIn.ok) expect(optIn.settings.preserveEmptyLines).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -239,6 +239,7 @@ describe("settings search", () => {
|
||||
{ titleKey: "settings.sqlFormatterLogicalOperatorNewline", category: "formatter", targetId: "formatter" },
|
||||
{ titleKey: "settings.sqlFormatterExpressionWidth", category: "formatter", targetId: "formatter" },
|
||||
{ titleKey: "settings.sqlFormatterLinesBetweenQueries", category: "formatter", targetId: "formatter" },
|
||||
{ titleKey: "settings.sqlFormatterPreserveEmptyLines", category: "formatter", targetId: "formatter" },
|
||||
{ titleKey: "settings.sqlFormatterDenseOperators", category: "formatter", targetId: "formatter" },
|
||||
{ titleKey: "settings.sqlFormatterNewlineBeforeSemicolon", category: "formatter", targetId: "formatter" },
|
||||
{ titleKey: "settings.sqlFormatterParamTypes", category: "formatter", targetId: "formatter" },
|
||||
|
||||
@@ -142,6 +142,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "formatter-from-clause-layout", category: "formatter", titleKey: "settings.sqlFormatterFromClauseLayout", targetId: "formatter" },
|
||||
{ id: "formatter-expression-width", category: "formatter", titleKey: "settings.sqlFormatterExpressionWidth", targetId: "formatter" },
|
||||
{ id: "formatter-lines-between-queries", category: "formatter", titleKey: "settings.sqlFormatterLinesBetweenQueries", targetId: "formatter" },
|
||||
{ id: "formatter-preserve-empty-lines", category: "formatter", titleKey: "settings.sqlFormatterPreserveEmptyLines", targetId: "formatter" },
|
||||
{ id: "formatter-dense-operators", category: "formatter", titleKey: "settings.sqlFormatterDenseOperators", targetId: "formatter" },
|
||||
{ id: "formatter-newline-before-semicolon", category: "formatter", titleKey: "settings.sqlFormatterNewlineBeforeSemicolon", targetId: "formatter" },
|
||||
{ id: "formatter-param-types", category: "formatter", titleKey: "settings.sqlFormatterParamTypes", targetId: "formatter" },
|
||||
|
||||
@@ -118,6 +118,95 @@ function splitTrailingStandaloneDot(sql: string): { body: string; suffix: string
|
||||
};
|
||||
}
|
||||
|
||||
interface EmptyLineProtection {
|
||||
sql: string;
|
||||
markers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces blank source lines with unique line comments while the third-party
|
||||
* formatter runs. sql-formatter intentionally normalizes whitespace, whereas
|
||||
* the optional DBX setting needs to retain visual paragraph boundaries such as
|
||||
* the blank line between a heading comment and a query. Line comments are
|
||||
* valid at every SQL code boundary and are restored only after all DBX layout
|
||||
* post-processing is complete.
|
||||
*/
|
||||
function protectEmptyLines(sql: string): EmptyLineProtection {
|
||||
let namespace = 0;
|
||||
while (sql.includes(`__DBX_PRESERVE_EMPTY_LINE_${namespace}_`)) namespace += 1;
|
||||
|
||||
const markers: string[] = [];
|
||||
const lineBreakPattern = /\r\n|\r|\n/g;
|
||||
let output = "";
|
||||
let lineStart = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = lineBreakPattern.exec(sql))) {
|
||||
const line = sql.slice(lineStart, match.index);
|
||||
if (line.trim().length === 0) {
|
||||
const marker = `-- __DBX_PRESERVE_EMPTY_LINE_${namespace}_${markers.length}__`;
|
||||
markers.push(marker);
|
||||
output += marker;
|
||||
} else {
|
||||
output += line;
|
||||
}
|
||||
output += match[0];
|
||||
lineStart = lineBreakPattern.lastIndex;
|
||||
}
|
||||
|
||||
// A final non-terminated blank line is still a user-authored empty line.
|
||||
// Do not manufacture one after a trailing line break, though: that would
|
||||
// turn the normal EOF sentinel into an additional preserved blank line.
|
||||
const finalLine = sql.slice(lineStart);
|
||||
if (finalLine.length > 0 && finalLine.trim().length === 0) {
|
||||
const marker = `-- __DBX_PRESERVE_EMPTY_LINE_${namespace}_${markers.length}__`;
|
||||
markers.push(marker);
|
||||
output += marker;
|
||||
} else {
|
||||
output += finalLine;
|
||||
}
|
||||
|
||||
return { sql: markers.length > 0 ? output : sql, markers };
|
||||
}
|
||||
|
||||
function restoreProtectedEmptyLines(sql: string, markers: readonly string[]): string {
|
||||
if (markers.length === 0) return sql;
|
||||
|
||||
const markerSet = new Set(markers);
|
||||
const lines = sql.split(/\r\n|\r|\n/);
|
||||
for (let index = 0; index < lines.length; ) {
|
||||
if (!markerSet.has(lines[index].trim())) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let runEnd = index;
|
||||
while (runEnd < lines.length && markerSet.has(lines[runEnd].trim())) runEnd += 1;
|
||||
const markerCount = runEnd - index;
|
||||
|
||||
// sql-formatter adds `linesBetweenQueries` blank lines before a marker
|
||||
// group placed between statements. Those lines did not exist in the
|
||||
// source—the marker group itself represents the source blank lines—so
|
||||
// remove at most one generated line per original blank line. This keeps
|
||||
// the larger of the user-authored spacing and the formatter's configured
|
||||
// query spacing instead of adding the two counts together.
|
||||
let removed = 0;
|
||||
while (removed < markerCount && index > 0 && lines[index - 1].trim().length === 0) {
|
||||
lines.splice(index - 1, 1);
|
||||
index -= 1;
|
||||
runEnd -= 1;
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
for (let markerIndex = index; markerIndex < runEnd; markerIndex += 1) {
|
||||
lines[markerIndex] = "";
|
||||
}
|
||||
index = runEnd;
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const DUCKDB_ALIAS_IDENTIFIER_START_RE = /[\p{L}_]/u;
|
||||
const DUCKDB_ALIAS_IDENTIFIER_CHAR_RE = /[\p{L}\p{N}_]/u;
|
||||
|
||||
@@ -253,7 +342,9 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
||||
const normalizedSettings = normalizeSqlFormatterSettings(settings);
|
||||
const options = sqlFormatterOptions(normalizedSettings);
|
||||
const language = formatterLanguage(dialect);
|
||||
const protectedInput = dialect === "duckdb" ? protectDuckDbPrefixAliasSeparators(sql) : { sql, marker: null };
|
||||
const emptyLineProtection = normalizedSettings.preserveEmptyLines ? protectEmptyLines(sql) : null;
|
||||
const sqlWithProtectedEmptyLines = emptyLineProtection?.sql ?? sql;
|
||||
const protectedInput = dialect === "duckdb" ? protectDuckDbPrefixAliasSeparators(sqlWithProtectedEmptyLines) : { sql: sqlWithProtectedEmptyLines, marker: null };
|
||||
const formatterOptions =
|
||||
language === "postgresql"
|
||||
? {
|
||||
@@ -290,16 +381,20 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeFormattedSql = (formatted: string): string => {
|
||||
const restoredDuckDbAliases = restoreDuckDbPrefixAliasSeparators(formatted, protectedInput.marker);
|
||||
const laidOut = applySqlFormatterLayout(restoredDuckDbAliases, normalizedSettings, dialect);
|
||||
return emptyLineProtection ? restoreProtectedEmptyLines(laidOut, emptyLineProtection.markers) : laidOut;
|
||||
};
|
||||
|
||||
try {
|
||||
const formatted = restoreDuckDbPrefixAliasSeparators(formatWithFallback(protectedInput.sql), protectedInput.marker);
|
||||
return applySqlFormatterLayout(formatted, normalizedSettings, dialect);
|
||||
return finalizeFormattedSql(formatWithFallback(protectedInput.sql));
|
||||
} catch (err) {
|
||||
const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(sql) : null;
|
||||
const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(protectedInput.sql) : null;
|
||||
if (!trailingDot) throw err;
|
||||
|
||||
try {
|
||||
const formatted = applySqlFormatterLayout(formatWithFallback(trailingDot.body), normalizedSettings, dialect);
|
||||
return `${formatted}${trailingDot.suffix}`;
|
||||
return finalizeFormattedSql(`${formatWithFallback(trailingDot.body)}${trailingDot.suffix}`);
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface SqlFormatterOptionSettings {
|
||||
fromClauseLayout: SqlFormatterFromClauseLayout;
|
||||
expressionWidth: SqlFormatterExpressionWidth;
|
||||
linesBetweenQueries: SqlFormatterLinesBetweenQueries;
|
||||
preserveEmptyLines: boolean;
|
||||
denseOperators: boolean;
|
||||
newlineBeforeSemicolon: boolean;
|
||||
paramTypes: SqlFormatterParamTypes | null;
|
||||
@@ -76,6 +77,7 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
|
||||
fromClauseLayout: "newLine",
|
||||
expressionWidth: 50,
|
||||
linesBetweenQueries: 1,
|
||||
preserveEmptyLines: false,
|
||||
denseOperators: false,
|
||||
newlineBeforeSemicolon: false,
|
||||
paramTypes: null,
|
||||
@@ -93,6 +95,7 @@ const SQL_FORMATTER_OPTION_KEYS = new Set<keyof SqlFormatterOptionSettings>([
|
||||
"fromClauseLayout",
|
||||
"expressionWidth",
|
||||
"linesBetweenQueries",
|
||||
"preserveEmptyLines",
|
||||
"denseOperators",
|
||||
"newlineBeforeSemicolon",
|
||||
"paramTypes",
|
||||
@@ -110,6 +113,7 @@ const SQL_FORMATTER_OPTION_VALIDATORS: Record<keyof SqlFormatterOptionSettings,
|
||||
fromClauseLayout: (value) => isStringChoice(value, FROM_CLAUSE_LAYOUT_VALUES),
|
||||
expressionWidth: (value) => isNumberChoice(value, EXPRESSION_WIDTH_VALUES),
|
||||
linesBetweenQueries: (value) => isNumberChoice(value, LINES_BETWEEN_QUERIES_VALUES),
|
||||
preserveEmptyLines: (value) => typeof value === "boolean",
|
||||
denseOperators: (value) => typeof value === "boolean",
|
||||
newlineBeforeSemicolon: (value) => typeof value === "boolean",
|
||||
paramTypes: isSqlFormatterParamTypes,
|
||||
@@ -191,6 +195,7 @@ export function sqlFormatterOptionSettings(settings: unknown): SqlFormatterOptio
|
||||
fromClauseLayout: normalizeChoice(input.fromClauseLayout, FROM_CLAUSE_LAYOUT_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.fromClauseLayout),
|
||||
expressionWidth: normalizeNumberChoice(input.expressionWidth, EXPRESSION_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.expressionWidth),
|
||||
linesBetweenQueries: normalizeNumberChoice(input.linesBetweenQueries, LINES_BETWEEN_QUERIES_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.linesBetweenQueries),
|
||||
preserveEmptyLines: normalizeBoolean(input.preserveEmptyLines, DEFAULT_SQL_FORMATTER_SETTINGS.preserveEmptyLines),
|
||||
denseOperators: normalizeBoolean(input.denseOperators, DEFAULT_SQL_FORMATTER_SETTINGS.denseOperators),
|
||||
newlineBeforeSemicolon: normalizeBoolean(input.newlineBeforeSemicolon, DEFAULT_SQL_FORMATTER_SETTINGS.newlineBeforeSemicolon),
|
||||
paramTypes: normalizeParamTypes(input.paramTypes, DEFAULT_SQL_FORMATTER_SETTINGS.paramTypes),
|
||||
|
||||
Reference in new issue
Block a user