feat(formatter): add custom default SQL layout engine

This commit is contained in:
t8y2 authored and GitHub committed 2026-09-17 17:21:47 +08:00
1 parent 58200ce5a4
commit 455117b8c5
20 files changed
+1588 -175

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
import type { AstNode, ClauseNode, ParenthesisNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
import { CREATE_TABLE_KEYWORD, keywordText, renderInline, splitByComma, Writer, type SqlLayoutContext } from "./primitives";
/**
* Column alignment for `CREATE TABLE`.
*
* The definition list is laid out on a character grid so the three parts of
* every column — name, type, nullability/default — line up, which is what makes
* a wide table readable:
*
* `id` varchar(20) PRIMARY KEY NOT NULL COMMENT '主键',
* `delivery_type_ids` varchar(512) DEFAULT NULL COMMENT '…',
* `update_time` datetime NOT NULL COMMENT '…'
*
* The definition list goes one column per line under its own `(`, and the table
* options move below the closing `)`.
*
* This is applied only to statements that match the shape exactly (see
* `isCreateTable`). Anything else — a `CREATE TABLE ... AS SELECT`, a dialect
* keyword sql-formatter parses differently — keeps the ordinary query layout
* rather than being aligned on a guess.
*/
/** The pieces of one column definition, already rendered. */
interface ColumnDefinition {
name: string;
type: string;
modifiers: string;
/** Everything from `COMMENT` onwards. */
tail: string;
}
type Definition = { kind: "column"; column: ColumnDefinition } | { kind: "other"; nodes: AstNode[] };
function isDataType(node: AstNode | undefined): boolean {
return node?.type === "data_type" || node?.type === "parameterized_data_type";
}
function isCommentNode(node: AstNode | undefined): boolean {
return node?.type === "identifier" && node.text.toUpperCase() === "COMMENT";
}
/**
* Splits one comma-separated definition into its aligned parts, or `null` when
* the item is not a plain `name type [modifiers] [COMMENT …]` column.
*/
function splitColumn(ctx: SqlLayoutContext, item: AstNode[]): ColumnDefinition | null {
const [nameNode, typeNode] = item;
if (nameNode?.type !== "identifier" || !isDataType(typeNode)) return null;
const commentAt = item.findIndex((node, index) => index >= 2 && isCommentNode(node));
const modifierNodes = item.slice(2, commentAt === -1 ? item.length : commentAt);
const tailNodes = commentAt === -1 ? [] : item.slice(commentAt);
const name = renderInline(ctx, [nameNode], ctx.options.lineWidth);
const type = renderInline(ctx, [typeNode], ctx.options.lineWidth);
const modifiers = renderInline(ctx, modifierNodes, ctx.options.lineWidth);
const tail = renderInline(ctx, tailNodes, ctx.options.lineWidth);
if (!name || !type || (modifierNodes.length > 0 && !modifiers) || (tailNodes.length > 0 && !tail)) return null;
return { name, type, modifiers: modifiers ?? "", tail: tail ?? "" };
}
/**
* Whether `statement` is a `CREATE TABLE` whose body is a list of column
* definitions this module can align. Everything else keeps the query layout.
*/
export function isCreateTable(statement: StatementNode): boolean {
return createTableParts(statement) !== null;
}
function createTableParts(statement: StatementNode): { clause: ClauseNode; name: AstNode; paren: ParenthesisNode; rest: AstNode[] } | null {
if (statement.children.length !== 1) return null;
const clause = statement.children[0];
if (clause.type !== "clause") return null;
if (!CREATE_TABLE_KEYWORD.test(clause.nameKw.text.trim())) return null;
const [name, paren] = clause.children;
if (name?.type !== "identifier" || paren?.type !== "parenthesis") return null;
// A `CREATE TABLE ... AS SELECT` has no column list to align.
if (paren.children.length === 0) return null;
return { clause, name, paren, rest: clause.children.slice(2) };
}
/** Emits a `CREATE TABLE`, aligning the definition list. */
export function createTableLayout(statement: StatementNode, ctx: SqlLayoutContext): string {
const parts = createTableParts(statement);
if (!parts) throw new Error("createTableLayout called on a statement that is not a CREATE TABLE");
const { clause, name, paren, rest } = parts;
const writer = new Writer(ctx.options);
const indent = ctx.options.indentWidth;
const nameText = renderInline(ctx, [name], ctx.options.lineWidth - clause.nameKw.text.length - 1);
if (!nameText) throw new Error("a CREATE TABLE table name must fit on one line");
writer.write(`${keywordText(clause.nameKw.text, ctx)} ${nameText}`);
writer.newline(0);
writer.write("(");
const definitions: Definition[] = splitByComma(paren.children).map((item) => {
const column = splitColumn(ctx, item);
return column ? { kind: "column", column } : { kind: "other", nodes: item };
});
// Column widths are shared by every line, so measure before emitting.
const columns = definitions.flatMap((definition) => (definition.kind === "column" ? [definition.column] : []));
const nameWidth = Math.max(0, ...columns.map((column) => column.name.length));
const typeWidth = Math.max(0, ...columns.map((column) => column.type.length));
const modifierWidth = Math.max(0, ...columns.map((column) => column.modifiers.length));
definitions.forEach((definition, index) => {
writer.newline(indent);
if (definition.kind === "column") {
const { name: columnName, type, modifiers, tail } = definition.column;
writer.write(columnName.padEnd(nameWidth));
if (type) writer.write(` ${type.padEnd(typeWidth)}`);
if (modifiers) writer.write(` ${modifiers.padStart(modifierWidth)}`);
if (tail) writer.write(` ${tail}`);
} else {
emitDefinition(writer, definition.nodes, indent, ctx);
}
if (index < definitions.length - 1) writer.write(",");
});
writer.newline(0);
writer.write(")");
if (rest.length > 0) {
const options = renderInline(ctx, rest, ctx.options.lineWidth - 1);
if (options) {
writer.write(` ${options}`);
} else {
writer.newline(indent);
writer.write(ctx.renderers.block(rest).trim());
}
}
if (statement.hasSemicolon) writer.write(";");
return writer.toString();
}
/**
* Emits a definition that is not a plain column — a table constraint. The
* constraint name gets its own line and its body is indented one step.
*/
function emitDefinition(writer: Writer, nodes: AstNode[], indent: number, ctx: SqlLayoutContext): void {
const flat = renderInline(ctx, nodes, ctx.options.lineWidth - indent);
if (flat) {
writer.write(flat);
return;
}
const [first, second] = nodes;
if (first?.type === "keyword" && second?.type === "identifier") {
writer.write(`${keywordText(first.text, ctx)} ${renderInline(ctx, [second], ctx.options.lineWidth) ?? second.text}`);
writer.newline(indent + ctx.options.indentWidth);
writer.write(ctx.renderers.block(nodes.slice(2)).trim());
return;
}
writer.write(ctx.renderers.block(nodes).trim());
}
+83
View File
@@ -0,0 +1,83 @@
import type { DialectOptions } from "sql-formatter/dist/esm/dialect.js";
import { createRenderers, loadEngine, type SqlFormatterEngine, type SqlFormatterParamValues } from "./internals";
import { printSqlLayout, type SqlLayoutOptions } from "./printer";
export type { SqlLayoutOptions } from "./printer";
/**
* Default layout knobs: a 120-column hard wrap, element lists of up to three
* kept on one line, a two-space indent, and joins indented one step below their
* FROM clause.
*/
export const DEFAULT_SQL_LAYOUT_OPTIONS: SqlLayoutOptions = {
lineWidth: 120,
keepElementsOnOneLine: 3,
indentWidth: 2,
joinIndentWidth: 4,
keywordCase: "preserve",
logicalOperatorNewline: "before",
linesBetweenQueries: 1,
useTabs: false,
fromClauseSourceOnSameLine: true,
};
export interface SqlLayoutRequest {
sql: string;
/** sql-formatter dialect key, e.g. `mysql` / `postgresql` / `plsql`. */
language: string;
/**
* The dialect options to parse with, when the caller has already resolved
* them. Parsing with anything but the caller's own dialect would make this
* layout disagree with the statements the public formatter produces for the
* same input — DBX's ClickHouse tweak, which keeps date-part abbreviations
* out of the reserved list, is exactly such a case.
*/
dialectOptions?: DialectOptions;
/** sql-formatter `FormatOptions` (casing flags, tab width, param types, ...). */
cfg: Record<string, unknown>;
options?: Partial<SqlLayoutOptions>;
}
function dialectFor(engine: SqlFormatterEngine, request: SqlLayoutRequest) {
const dialectOptions = request.dialectOptions ?? engine.dialects[request.language] ?? engine.dialects.sql;
return engine.createDialect(dialectOptions);
}
/**
* Formats `sql` with the default style's layout rules.
*
* Returns `null` when sql-formatter's internals are unavailable or the input
* cannot be parsed, so callers can fall back to the public `format()` API
* instead of surfacing a failure to the user.
*/
export async function formatSqlLayout(request: SqlLayoutRequest): Promise<string | null> {
const engine = await loadEngine();
if (!engine) return null;
const dialect = dialectFor(engine, request);
const options: SqlLayoutOptions = {
...DEFAULT_SQL_LAYOUT_OPTIONS,
keywordCase: (request.cfg.keywordCase as SqlLayoutOptions["keywordCase"]) ?? "preserve",
...request.options,
};
// `indentStyle` selects sql-formatter's tabular layouts, which pad every clause
// to a fixed column, and `useTabs` makes it indent with tabs. The rules here
// replace that layout and write their own indentation, so both are pinned: the
// renderers must emit space-indented text relative to column 0, which the
// printer then re-bases onto the column it measured.
// The annotation keeps the index signature spread objects would otherwise lose,
// so `paramTypes`/`params` stay readable below.
const cfg: Record<string, unknown> = { ...request.cfg, indentStyle: "standard", useTabs: false };
try {
const statements = engine.parse(request.sql, dialect, (cfg.paramTypes as Record<string, unknown>) ?? {});
return printSqlLayout(statements, {
renderers: createRenderers(engine, dialect, cfg, new engine.Params(cfg.params as SqlFormatterParamValues), options.indentWidth),
options,
});
} catch {
// Parse failures are the formatter's normal "not supported yet" signal; the
// caller degrades to sql-formatter's own output or to the original text.
return null;
}
}
@@ -0,0 +1,198 @@
import type { Dialect, DialectOptions } from "sql-formatter/dist/esm/dialect.js";
import type ExpressionFormatter from "sql-formatter/dist/esm/formatter/ExpressionFormatter.js";
import type Indentation from "sql-formatter/dist/esm/formatter/Indentation.js";
import type InlineLayout from "sql-formatter/dist/esm/formatter/InlineLayout.js";
import type Layout from "sql-formatter/dist/esm/formatter/Layout.js";
import type Params from "sql-formatter/dist/esm/formatter/Params.js";
import type { AstNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
/**
* Access to sql-formatter's bundled parser and layout machinery.
*
* sql-formatter's public surface is `format()` / `formatDialect()`, and its
* output layout is not configurable enough for the default style DBX targets:
* clause keywords must sit flush left, a clause's continuation lines align under
* its first item, and parenthesized groups stay inline while they fit. All of
* that needs the AST plus the "measure, don't wrap" layout the package only
* ships internally, so these imports go through the `sql-formatter/dist/` alias
* set up in vite.config.ts and vitest.config.ts.
*
* This module is the single place that reaches into the package internals. If a
* dependency bump ever breaks one of these shapes, `loadEngine()` reports that
* and every caller falls back to the public `format()` API rather than throwing.
*/
type InlineLayoutCtor = typeof InlineLayout;
type InlineLayoutErrorCtor = typeof import("sql-formatter/dist/esm/formatter/InlineLayout.js").InlineLayoutError;
type ExpressionFormatterCtor = typeof ExpressionFormatter;
type ParamsCtor = typeof Params;
type LayoutCtor = typeof Layout;
type IndentationCtor = typeof Indentation;
/**
* The values `Params` resolves placeholders against, as sql-formatter types them:
* `cfg.params`, which DBX never sets — the config importer rejects that legacy
* key — so placeholders keep their literal text in the output.
*/
export type SqlFormatterParamValues = ConstructorParameters<ParamsCtor>[0];
/** The subset of sql-formatter's internals the layout printer needs. */
export interface SqlFormatterEngine {
/** Every bundled dialect, keyed by the name `format()` accepts. */
readonly dialects: Record<string, DialectOptions>;
readonly createDialect: (options: DialectOptions) => Dialect;
readonly parse: (sql: string, dialect: Dialect, paramTypes: Record<string, unknown>) => StatementNode[];
readonly ExpressionFormatter: ExpressionFormatterCtor;
readonly InlineLayout: InlineLayoutCtor;
readonly InlineLayoutError: InlineLayoutErrorCtor;
readonly Params: ParamsCtor;
readonly Layout: LayoutCtor;
readonly Indentation: IndentationCtor;
}
let enginePromise: Promise<SqlFormatterEngine | null> | null = null;
async function importEngine(): Promise<SqlFormatterEngine | null> {
try {
// Literal specifiers only: Vite cannot statically analyse a computed path,
// and these must resolve through the `sql-formatter/dist/` alias.
const [parserMod, dialectMod, allDialects, expressionMod, inlineLayoutMod, paramsMod, layoutMod, indentationMod] = await Promise.all([
import("sql-formatter/dist/esm/parser/createParser.js"),
import("sql-formatter/dist/esm/dialect.js"),
import("sql-formatter/dist/esm/allDialects.js"),
import("sql-formatter/dist/esm/formatter/ExpressionFormatter.js"),
import("sql-formatter/dist/esm/formatter/InlineLayout.js"),
import("sql-formatter/dist/esm/formatter/Params.js"),
import("sql-formatter/dist/esm/formatter/Layout.js"),
import("sql-formatter/dist/esm/formatter/Indentation.js"),
]);
const createParser = parserMod.createParser;
const createDialect = dialectMod.createDialect;
const ExpressionFormatter = expressionMod.default;
const InlineLayout = inlineLayoutMod.default;
const InlineLayoutError = inlineLayoutMod.InlineLayoutError;
const Params = paramsMod.default;
const Layout = layoutMod.default;
const Indentation = indentationMod.default;
if (
typeof createParser !== "function" ||
typeof createDialect !== "function" ||
typeof ExpressionFormatter !== "function" ||
typeof InlineLayout !== "function" ||
typeof InlineLayoutError !== "function" ||
typeof Params !== "function" ||
typeof Layout !== "function" ||
typeof Indentation !== "function" ||
!allDialects ||
typeof allDialects !== "object"
) {
return null;
}
return {
dialects: allDialects as unknown as Record<string, DialectOptions>,
createDialect,
parse: (sql, dialect, paramTypes) => createParser(dialect.tokenizer).parse(sql, paramTypes),
ExpressionFormatter,
InlineLayout,
InlineLayoutError,
Params,
Layout,
Indentation,
};
} catch {
// Older/newer sql-formatter layouts move these files around. Report "no
// engine" so the caller uses the public formatter instead of failing the
// user's format action outright.
return null;
}
}
/** Loads (and caches) the engine, or `null` when the internals are unavailable. */
export function loadEngine(): Promise<SqlFormatterEngine | null> {
enginePromise ??= importEngine();
return enginePromise;
}
/**
* The two rendering primitives the layout printer builds on.
*
* Both render *relative to column 0*; positioning is the printer's job, which is
* what lets it align clause items under a column that depends on the enclosing
* `(` rather than on a fixed indent level.
*/
export interface SqlLayoutRenderers {
/** Renders `nodes` on a single line, or `null` when they do not fit `width`. */
inline(nodes: AstNode[], width: number): string | null;
/** Renders `nodes` on one or more lines, indented relative to column 0. */
block(nodes: AstNode[]): string;
/**
* Whether the dialect writes `text` without a space in front of it.
*
* The printer renders a parenthesis group and its surroundings in separate
* steps, so it decides the separating space itself and has to agree with the
* renderer: sql-formatter drops the space for a "dense" operator such as
* PostgreSQL's `::`, and `cfg.denseOperators` makes every operator dense.
*/
denseOperator(text: string): boolean;
}
/**
* Measures `nodes` with sql-formatter's `InlineLayout`, returning `null` instead
* of throwing when the group cannot be a single line.
*
* `InlineLayout` raises for both reasons we care about — a construct that
* genuinely requires a line break (a clause, a `--` comment) and text that would
* overflow `width` — so one call answers "would this fit on the line?" exactly,
* which is what the collapse rule is decided on.
*/
export function renderInline(engine: SqlFormatterEngine, nodes: AstNode[], dialect: Dialect, cfg: Record<string, unknown>, params: InstanceType<ParamsCtor>, width: number): string | null {
if (nodes.length === 0 || width <= 0) return null;
// `formatParenthesis` measures nested groups against `cfg.expressionWidth`, so
// point it at the width available here. Rendering is synchronous, and the
// previous value is restored below.
const previousWidth = cfg.expressionWidth;
cfg.expressionWidth = width;
try {
const formatter = new engine.ExpressionFormatter({
cfg: cfg as never,
dialectCfg: dialect.formatOptions,
params: params as never,
layout: new engine.InlineLayout(width) as never,
inline: true,
});
// No whitespace collapsing here: an inline layout never emits a newline (it
// raises first) and squashing runs of spaces would corrupt string literals.
const text = formatter.format(nodes).toString().trim();
return text || null;
} catch (error) {
if (error instanceof engine.InlineLayoutError) return null;
throw error;
} finally {
cfg.expressionWidth = previousWidth;
}
}
/** Creates the inline/block renderer pair for one dialect and configuration. */
export function createRenderers(engine: SqlFormatterEngine, dialect: Dialect, cfg: Record<string, unknown>, params: InstanceType<ParamsCtor>, indentWidth: number): SqlLayoutRenderers {
const indent = " ".repeat(Math.max(1, indentWidth));
const alwaysDenseOperators = dialect.formatOptions.alwaysDenseOperators ?? [];
return {
inline: (nodes, width) => renderInline(engine, nodes, dialect, cfg, params, width),
block: (nodes) => {
const layout = new engine.Layout(new engine.Indentation(indent));
const formatter = new engine.ExpressionFormatter({
cfg: cfg as never,
dialectCfg: dialect.formatOptions,
params: params as never,
layout: layout as never,
inline: false,
});
return formatter.format(nodes).toString();
},
denseOperator: (text) => cfg.denseOperators === true || alwaysDenseOperators.includes(text),
};
}
@@ -0,0 +1,440 @@
import type { AstNode, ClauseNode, KeywordNode, LimitClauseNode, ParenthesisNode, SetOperationNode } from "sql-formatter/dist/esm/parser/ast.js";
import type { SqlLayoutRenderers } from "./internals";
/**
* The building blocks both layout strategies share: a column-tracking output
* buffer, the width measurements that decide whether a group fits, and the
* helpers that recognise the structural nodes of a statement body.
*
* The layout rules themselves live in `printer.ts` (queries) and `ddl.ts`
* (`CREATE TABLE` column alignment).
*/
export interface SqlLayoutOptions {
/** Hard wrap column; a group wider than this is broken across lines. */
lineWidth: number;
/** Maximum number of clause items kept on the keyword's line. */
keepElementsOnOneLine: number;
/** One indent step, in columns. */
indentWidth: number;
/** Extra indent applied to JOIN keywords, in columns. */
joinIndentWidth: number;
/** Keyword casing, matching sql-formatter's `keywordCase`. */
keywordCase: "preserve" | "upper" | "lower";
/**
* Where a wrapped condition puts its `AND`/`OR`, matching DBX's own
* "logical operator newline" setting.
*
* `before` starts the continuation line with the operator, `after` ends the
* previous line with it, and `none` keeps the whole condition on one line
* wherever it fits, matching the element alignment rule.
*/
logicalOperatorNewline: "before" | "after" | "none";
/** Blank lines left between two statements. */
linesBetweenQueries: number;
/** Whether indentation is written with tab characters. */
useTabs: boolean;
/**
* Whether a `FROM` clause's first source stays on the keyword's line.
*
* The default keeps it there; DBX exposes the alternative as its
* "FROM clause" setting.
*/
fromClauseSourceOnSameLine: boolean;
}
export interface SqlLayoutContext {
renderers: SqlLayoutRenderers;
options: SqlLayoutOptions;
/**
* Parentheses that must not be glued to the name before them, even though it
* is an identifier. Set only for an `INSERT` target's column list; see
* {@link clauseChildContext}.
*/
nonCallParens?: ReadonlySet<AstNode>;
/**
* Whether a condition may be joined onto one line here.
*
* Defaults to `logicalOperatorNewline === "none"`: the other two values exist
* precisely because the caller wants the operators broken out. Collapse
* measurements override it — see {@link collapsedContext}.
*/
joinLogicalOperators?: boolean;
}
/**
* Column-tracking output buffer.
*
* Positions are tracked as display columns rather than string offsets, because
* the layout aligns continuations under a column that depends on the enclosing
* `(`. With `useTabs` only the leading indentation becomes tabs — the padding
* used for alignment stays spaces, so a column target that is not a multiple of
* the tab width still lands where it was measured.
*/
export class Writer {
private lines: string[] = [""];
private columns: number[] = [0];
constructor(private readonly options: Pick<SqlLayoutOptions, "useTabs" | "indentWidth">) {}
/** Column the cursor currently sits at, on the last line. */
get column(): number {
return this.columns[this.columns.length - 1];
}
/** Appends text verbatim. */
write(text: string): void {
this.lines[this.lines.length - 1] += text;
this.columns[this.columns.length - 1] += text.length;
}
/** Appends a single separating space, unless one is already there. */
space(): void {
const line = this.lines[this.lines.length - 1];
if (line.length > 0 && !line.endsWith(" ")) {
this.lines[this.lines.length - 1] = `${line} `;
this.columns[this.columns.length - 1] += 1;
}
}
/** Starts a new line at `indent` columns, dropping trailing blanks first. */
newline(indent: number): void {
const last = this.lines.length - 1;
this.lines[last] = this.lines[last].trimEnd();
const column = Math.max(0, indent);
this.lines.push(this.indentation(column));
this.columns.push(column);
}
toString(): string {
return this.lines
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
/** The whitespace achieving `column` columns of indentation. */
private indentation(column: number): string {
if (!this.options.useTabs) return " ".repeat(column);
const width = Math.max(1, this.options.indentWidth);
return "\t".repeat(Math.floor(column / width)) + " ".repeat(column % width);
}
}
export function isParenthesis(node: AstNode): node is ParenthesisNode {
return node.type === "parenthesis";
}
/**
* The keyword a node opens its line with, or `null` for a node that is part of
* an expression. Clauses, set operations and limit clauses each start a line.
*/
export function bodyKeyword(node: AstNode): string | null {
if (node.type === "clause" || node.type === "set_operation") {
return (node as ClauseNode | SetOperationNode).nameKw.text;
}
if (node.type === "limit_clause") {
return (node as LimitClauseNode).limitKw.text;
}
return null;
}
export function isBodyNode(node: AstNode): boolean {
return bodyKeyword(node) !== null;
}
/**
* The context a collapse measurement renders in.
*
* The collapse rule *joins* statements the element placement would otherwise
* spread over several lines, so a condition inside a
* statement — or a subquery — being measured for one line joins its operators
* even when the user asked for `logicalOperatorNewline: "before"`.
*/
export function collapsedContext(ctx: SqlLayoutContext): SqlLayoutContext {
return ctx.joinLogicalOperators ? ctx : { ...ctx, joinLogicalOperators: true };
}
/**
* The comma the parser does not materialise for `LIMIT 5, 10`, where the offset
* and the count are separate expression chains. sql-formatter writes the
* separator itself while formatting; here the two are joined as ordinary clause
* items, so the separator has to be an item.
*/
const LIMIT_SEPARATOR = { type: "comma" } as unknown as AstNode;
/** The expressions a `LIMIT` clause renders, in sql-formatter's own order. */
export function limitChildren(node: LimitClauseNode): AstNode[] {
return node.offset ? [...node.offset, LIMIT_SEPARATOR, ...node.count] : [...node.count];
}
export function keywordText(text: string, ctx: SqlLayoutContext): string {
switch (ctx.options.keywordCase) {
case "upper":
return text.toUpperCase();
case "lower":
return text.toLowerCase();
default:
return text;
}
}
export function splitByComma(children: AstNode[]): AstNode[][] {
const groups: AstNode[][] = [];
let current: AstNode[] = [];
for (const child of children) {
if (child.type === "comma") {
groups.push(current);
current = [];
} else {
current.push(child);
}
}
groups.push(current);
return groups;
}
/**
* Keywords that introduce an object name rather than an expression operand, so
* a parenthesis directly after that name is a definition list, not a call:
* `INTO t (a, b)`, `REFERENCES t (col)`, `ON t (col)`.
*/
const OBJECT_NAME_KEYWORD = /^(?:INTO|TABLE|VIEW|INDEX|REFERENCES|ON)$/i;
/**
* Whether `(` at `index` is a function call on the name before it. The default
* style writes `f(x)`, not `f (x)`; sql-formatter separates an unknown function name
* from its argument list because it cannot tell a call from a keyword.
*
* `INSERT INTO t (a, b)` and `REFERENCES t (col)` have the same shape but are
* not calls. The keyword in front of the name is what tells the two apart here;
* a statement head puts its object name in a clause of its own, where that
* keyword is not a sibling — see {@link clauseChildContext}.
*/
export function isCallParen(nodes: AstNode[], index: number, ctx: SqlLayoutContext): boolean {
const node = nodes[index];
if (ctx.nonCallParens?.has(node)) return false;
const previous = index > 0 ? nodes[index - 1] : undefined;
if (previous?.type !== "identifier" && previous?.type !== "property_access") return false;
const before = index > 1 ? nodes[index - 2] : undefined;
return !(before?.type === "keyword" && OBJECT_NAME_KEYWORD.test(before.text.trim()));
}
/** `CREATE TABLE` plus the modifiers dialects may put around it. */
const CREATE_TABLE_HEAD = "CREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:GLOBAL\\s+|LOCAL\\s+)?(?:TEMP(?:ORARY)?\\s+)?(?:UNLOGGED\\s+)?TABLE(?:\\s+IF\\s+NOT\\s+EXISTS)?";
/**
* A `CREATE TABLE` head. Shared with `ddl.ts` so that the column alignment and
* the definition-list rule below agree on exactly which statements they describe.
*/
export const CREATE_TABLE_KEYWORD = new RegExp(`^${CREATE_TABLE_HEAD}$`, "i");
/**
* Clause keywords whose object name is followed by a parenthesized *definition
* list* rather than call arguments: `INSERT INTO t (a, b)` takes column names,
* `CREATE TABLE t (id int)` takes column definitions.
*/
const DEFINITION_LIST_KEYWORD = new RegExp(`^(?:INSERT\\b[\\s\\S]*\\bINTO|${CREATE_TABLE_HEAD})$`, "i");
/**
* The context a clause's own children are rendered in.
*
* A statement head holds its object name as a clause child (`t` in
* `INSERT INTO t (a, b)`), so the keyword identifying it is the clause's own and
* {@link isCallParen}'s lookback cannot see it. Marking the group rather than
* the whole clause leaves every other parenthesis in the clause — an
* `ON DUPLICATE KEY UPDATE b = f(c)`, which the parser also puts here — a call.
*/
export function clauseChildContext(ctx: SqlLayoutContext, clause: ClauseNode): SqlLayoutContext {
if (clause.children.length < 2 || !DEFINITION_LIST_KEYWORD.test(clause.nameKw.text.trim())) return ctx;
const at = clause.children.findIndex(isParenthesis);
if (at <= 0) return ctx;
return { ...ctx, nonCallParens: new Set([...(ctx.nonCallParens ?? []), clause.children[at]]) };
}
/**
* Renders `nodes` on one line, or `null` when they do not fit in `width`.
*
* Two cases go beyond a straight call to `renderers.inline`:
*
* - Parenthesized groups are rendered here, by {@link renderParenGroup}. Going
* through our own rules at every nesting depth is what keeps `f(x)` free of a
* space — inside another call's arguments as much as at the top level.
* - A run of clauses is collapsible. `renderers.inline` refuses anything whose
* layout needs a line break, clauses included, but the default style does put
* `SELECT a FROM t` on one line when it fits, and the same rule keeps a small
* subquery inline.
*
* Logical operators are joined by {@link renderLogicalJoin} when the caller's
* `logicalOperatorNewline` asks for it, so a condition stays on its clause's line
* while it fits; the printer splits the operators onto their own lines otherwise.
*/
export function renderInline(ctx: SqlLayoutContext, nodes: AstNode[], width: number): string | null {
if (nodes.length === 0 || width <= 0) return null;
const parenAt = nodes.findIndex(isParenthesis);
if (parenAt >= 0) {
const group = renderParenGroup(ctx, nodes, parenAt, width);
if (group) return group;
}
const direct = ctx.renderers.inline(nodes, width);
if (direct) return direct;
const joinsOperators = ctx.joinLogicalOperators ?? ctx.options.logicalOperatorNewline === "none";
const joined = joinsOperators && nodes.some(isLogicalOperator) ? renderLogicalJoin(ctx, nodes, width) : null;
if (joined) return joined;
if (!nodes.some(isBodyNode)) return null;
const parts: string[] = [];
let used = 0;
for (const node of nodes) {
const keyword = bodyKeyword(node);
if (!keyword) return null;
const childCtx = node.type === "limit_clause" ? ctx : clauseChildContext(ctx, node as ClauseNode);
const children = node.type === "limit_clause" ? limitChildren(node as LimitClauseNode) : (node as ClauseNode).children;
const text = renderInline(childCtx, children, width - used - keyword.length - 2);
if (!text) return null;
used += (parts.length > 0 ? 1 : 0) + keyword.length + 1 + text.length;
if (used > width) return null;
parts.push(`${keywordText(keyword, ctx)} ${text}`);
}
return parts.join(" ");
}
/**
* Renders the parenthesis group at `at` and the rest of `nodes` behind it, or
* `null` when the line does not fit.
*
* The group is measured from the inside out: its contents get the width left
* after the name before it, and whatever follows gets what the group left. A
* call — a name directly in front of the group — is written without a
* separator, everything else with one, which is the only difference between
* `f(x)` and `IN (x)`.
*/
function renderParenGroup(ctx: SqlLayoutContext, nodes: AstNode[], at: number, width: number): string | null {
const node = nodes[at] as ParenthesisNode;
// A group that fits is a collapsed statement in its own right, so its contents
// join their logical operators whatever the caller's operator placement is.
const groupCtx = collapsedContext(ctx);
const head = at === 0 ? "" : renderInline(groupCtx, nodes.slice(0, at), width);
if (head === null) return null;
const separator = at === 0 || isCallParen(nodes, at, ctx) ? "" : " ";
const inner = renderInline(groupCtx, node.children, width - head.length - separator.length - node.openParen.length - node.closeParen.length);
if (!inner) return null;
const group = `${head}${separator}${node.openParen}${inner}${node.closeParen}`;
const rest = nodes.slice(at + 1);
if (rest.length === 0) return group.length <= width ? group : null;
const tail = renderInline(groupCtx, rest, width - group.length - 1);
if (!tail) return null;
const text = `${group}${separatorBefore(rest[0], ctx)}${tail}`;
return text.length <= width ? text : null;
}
/** The space that belongs between a rendered group and the node following it. */
function separatorBefore(node: AstNode, ctx: SqlLayoutContext): string {
if (node.type === "comma") return "";
if (node.type === "operator" && ctx.renderers.denseOperator(node.text)) return "";
return " ";
}
/** Whether `node` is an `AND`/`OR`/`XOR` joining two conditions. */
export function isLogicalOperator(node: AstNode): node is KeywordNode {
return node.type === "keyword" && (node.tokenType === "AND" || node.tokenType === "OR" || node.tokenType === "XOR");
}
/** Whether `node` opens a new join element — the `JOIN` keyword itself. */
export function isJoinKeyword(node: AstNode): node is KeywordNode {
return node.type === "keyword" && node.tokenType === "RESERVED_JOIN";
}
/**
* The operands of a condition, each after the first starting with the `AND`/`OR`
* that introduces it, or `null` when `nodes` is not such a chain.
*
* Only the operators at this level count: one nested inside a parenthesis belongs
* to that group, which is printed — and split — on its own.
*/
export function splitLogicalOperands(nodes: AstNode[]): AstNode[][] | null {
const operands: AstNode[][] = [];
let current: AstNode[] = [];
for (const node of nodes) {
if (!isLogicalOperator(node)) {
current.push(node);
continue;
}
if (current.length === 0) return null;
operands.push(current);
current = [node];
}
if (operands.length === 0 || current.length === 0) return null;
operands.push(current);
return operands;
}
/**
* Joins the operands of a condition onto one line, or returns `null` when the
* result would not fit.
*
* `renderers.inline` raises on a logical operator because sql-formatter always
* breaks the line there. The default style instead keeps a clause's elements on
* the clause's line and wraps them only past the hard wrap column, so `WHERE a = 1
* AND b = 2` is one line while `... AND <a long enough operand>` is not.
*
* Splitting here rather than relaxing the renderer keeps the refusal intact for
* the constructs that must stay multi-line: a JOIN or a CASE is not a split
* point, so neither is ever folded onto one line by this.
*/
function renderLogicalJoin(ctx: SqlLayoutContext, nodes: AstNode[], width: number): string | null {
const parts: string[] = [];
let run: AstNode[] = [];
let used = 0;
const flush = (): boolean => {
if (run.length === 0) return true;
const text = renderInline(ctx, run, width - used);
if (!text) return false;
used += text.length + 1;
parts.push(text);
run = [];
return true;
};
for (const node of nodes) {
if (!isLogicalOperator(node)) {
run.push(node);
continue;
}
if (!flush()) return null;
const operator = keywordText(node.text, ctx);
used += operator.length + 1;
parts.push(operator);
}
if (!flush()) return null;
return used - 1 <= width ? parts.join(" ") : null;
}
/**
* Writes possibly multi-line `text`, re-basing each continuation line on the
* column the block starts at. `text` is rendered relative to column 0, so its
* own leading whitespace carries the nesting levels and only needs shifting.
*/
export function emitText(writer: Writer, text: string, baseColumn: number): void {
const lines = text.split("\n");
writer.write(lines[0].trimEnd());
for (let index = 1; index < lines.length; index++) {
const line = lines[index];
if (line.trim() === "") {
writer.newline(0);
continue;
}
writer.newline(baseColumn + line.length - line.trimStart().length);
writer.write(line.trimStart());
}
}
+282
View File
@@ -0,0 +1,282 @@
import type { AstNode, ClauseNode, LimitClauseNode, ParenthesisNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
import { createTableLayout, isCreateTable } from "./ddl";
import { clauseChildContext, collapsedContext, emitText, isBodyNode, isCallParen, isJoinKeyword, isLogicalOperator, isParenthesis, keywordText, limitChildren, renderInline, splitByComma, splitLogicalOperands, Writer, type SqlLayoutContext } from "./primitives";
export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
/**
* SQL layout — DBX's default formatting style.
*
* The rules:
*
* - Clause keywords start at the block's left margin, with no padding after
* them.
* - A clause's first item stays on the keyword's line, and its remaining items
* align under the first item's column.
* - Short element lists stay collapsed.
* - A statement whose clauses all fit the line width is emitted as a single
* line.
* - Join keywords are indented relative to their FROM clause.
*
* Alignment is *column-relative*, not indent-level-relative: a subquery's
* clauses line up under the `(` that opens it, wherever that `(` happens to be.
* sql-formatter's `Layout`/`Indentation` model cannot express that, which is why
* this printer walks the AST itself and only delegates the rendering of
* individual expressions to sql-formatter (see `internals.ts`).
*
* Everything here degrades towards "expand": whenever a group cannot be measured
* or does not fit, it is laid out across multiple lines rather than truncated.
*/
/**
* Emits one clause: its keyword, then its items — the first kept on the keyword's
* line and the rest aligned under it — unless the whole list is short enough to
* stay on one line.
*/
function printClause(writer: Writer, clause: ClauseNode, baseColumn: number, ctx: SqlLayoutContext): void {
const keyword = keywordText(clause.nameKw.text, ctx);
const items = splitByComma(clause.children);
const itemColumn = baseColumn + keyword.length + 1;
// `FROM` is the one clause DBX lets users push its first source off of, so it
// skips both the collapse and the "element on the keyword's line" rule.
const sourceOnOwnLine = clause.nameKw.text.toUpperCase() === "FROM" && !ctx.options.fromClauseSourceOnSameLine;
const childCtx = clauseChildContext(ctx, clause);
if (!sourceOnOwnLine && items.length <= ctx.options.keepElementsOnOneLine) {
const flat = renderInline(childCtx, clause.children, ctx.options.lineWidth - writer.column - keyword.length - 1);
if (flat) {
writer.write(`${keyword} ${flat}`);
return;
}
}
items.forEach((item, index) => {
if (index === 0) {
writer.write(keyword);
if (sourceOnOwnLine) writer.newline(baseColumn + ctx.options.indentWidth);
else writer.space();
} else {
writer.newline(itemColumn);
}
printElement(writer, item, baseColumn, childCtx);
if (index < items.length - 1) writer.write(",");
});
}
/** Emits a `LIMIT` clause, which sql-formatter renders as keyword + expressions. */
function printLimitClause(writer: Writer, clause: LimitClauseNode, ctx: SqlLayoutContext): void {
const keyword = keywordText(clause.limitKw.text, ctx);
const children = limitChildren(clause);
const flat = renderInline(ctx, children, ctx.options.lineWidth - writer.column - keyword.length - 1);
if (flat) {
writer.write(`${keyword} ${flat}`);
return;
}
writer.write(keyword);
writer.newline(writer.column + ctx.options.indentWidth);
emitText(writer, ctx.renderers.block(children).trim(), writer.column);
}
/** Index just past the nodes that share a line with the group at `from`. */
function joinElementEnd(nodes: AstNode[], from: number): number {
let end = from;
while (end < nodes.length && !isJoinKeyword(nodes[end])) end += 1;
return end;
}
/**
* Renders the contents of the parenthesis at `index` on one line, or `null` when
* the group has to expand across lines.
*
* The measurement covers the rest of the join element, not just the group: a
* subquery stays inline only while `(SELECT ...) alias ON ...` fits the line as
* a whole. The group expands when it does not, rather than keeping a
* short subquery inline and pushing a long `ON` onto a line of its own.
*/
function measureParen(writer: Writer, nodes: AstNode[], index: number, ctx: SqlLayoutContext): string | null {
const node = nodes[index] as ParenthesisNode;
const width = ctx.options.lineWidth;
const afterOpen = writer.column + node.openParen.length;
const collapseCtx = collapsedContext(ctx);
const inner = renderInline(collapseCtx, node.children, width - afterOpen - node.closeParen.length);
if (!inner) return null;
const end = joinElementEnd(nodes, index + 1);
if (end === index + 1) return inner;
const remaining = width - afterOpen - inner.length - node.closeParen.length;
return renderInline(collapseCtx, nodes.slice(index + 1, end), remaining) ? inner : null;
}
/**
* Emits a condition that does not fit on one line, breaking it at its top-level
* logical operators the way `logicalOperatorNewline` asks: `before` starts each
* continuation line with the operator, `after` ends the previous line with it.
*
* The operands align under the first one, the same rule a clause's other items
* follow — the style keeps a clause's elements in one column rather than
* indenting them another step.
*/
function printCondition(writer: Writer, operands: AstNode[][], baseColumn: number, ctx: SqlLayoutContext): void {
const column = writer.column;
for (const [head, ...rest] of operands) {
if (!isLogicalOperator(head)) {
printElement(writer, [head, ...rest], baseColumn, ctx);
continue;
}
const operator = keywordText(head.text, ctx);
if (ctx.options.logicalOperatorNewline === "before") {
writer.newline(column);
writer.write(operator);
writer.space();
} else {
writer.space();
writer.write(operator);
writer.newline(column);
}
printElement(writer, rest, baseColumn, ctx);
}
}
/**
* Emits one clause item, expanding only those parentheses that do not fit on the
* current line.
*/
function printElement(writer: Writer, nodes: AstNode[], baseColumn: number, ctx: SqlLayoutContext): void {
const whole = renderInline(ctx, nodes, ctx.options.lineWidth - writer.column);
if (whole) {
writer.write(whole);
return;
}
// A condition is split at its own operators before anything else is tried, so
// `AND`/`OR` end up on the lines the setting asks for rather than wherever the
// block fallback happens to place them. `none` asks for no break of its own,
// and is folded back together after formatting (see the caller's post-pass).
const operands = ctx.options.logicalOperatorNewline === "none" ? null : splitLogicalOperands(nodes);
if (operands) {
printCondition(writer, operands, baseColumn, ctx);
return;
}
let index = 0;
while (index < nodes.length) {
const node = nodes[index];
if (isParenthesis(node)) {
if (!isCallParen(nodes, index, ctx)) writer.space();
const inner = measureParen(writer, nodes, index, ctx);
if (inner) {
writer.write(`${node.openParen}${inner}${node.closeParen}`);
} else {
writer.write(node.openParen);
printBlock(writer, node.children, writer.column, ctx);
writer.write(node.closeParen);
}
index += 1;
continue;
}
// A JOIN keyword opens a new line one "Join indent" in from the FROM clause.
if (isJoinKeyword(node)) {
writer.newline(baseColumn + ctx.options.joinIndentWidth);
writer.write(keywordText(node.text, ctx));
index += 1;
continue;
}
// Gather the run of plain nodes up to the next construct needing care, so
// they are measured together instead of one node at a time.
let end = index;
while (end < nodes.length && !isParenthesis(nodes[end]) && !isJoinKeyword(nodes[end])) {
end += 1;
}
const run = nodes.slice(index, end);
const rendered = renderInline(ctx, run, ctx.options.lineWidth - writer.column);
writer.space();
if (rendered) {
writer.write(rendered);
} else {
// A construct too wide to inline (a long CASE, a nested function call):
// fall back to sql-formatter's own multi-line rendering of it.
emitText(writer, ctx.renderers.block(run).trim(), writer.column);
}
index = end;
}
}
/**
* Emits a statement body: a subquery's clauses or a statement's own clauses.
*
* Called only once the caller has established that the body does not fit on one
* line, so there is no second collapse attempt here — re-flattening would undo
* the very expansion the caller asked for.
*/
function printBlock(writer: Writer, nodes: AstNode[], baseColumn: number, ctx: SqlLayoutContext): void {
if (!nodes.some(isBodyNode)) {
// A plain expression group (not a subquery) that did not fit: let
// sql-formatter break it, then re-base the block on this column.
emitText(writer, ctx.renderers.block(nodes).trim(), baseColumn);
return;
}
let first = true;
for (const node of nodes) {
if (!first) writer.newline(baseColumn);
writeBodyNode(writer, node, baseColumn, ctx);
first = false;
}
}
/** Emits one clause / set operation / limit clause of a statement body. */
function writeBodyNode(writer: Writer, node: AstNode, baseColumn: number, ctx: SqlLayoutContext): void {
if (node.type === "clause") printClause(writer, node, baseColumn, ctx);
else if (node.type === "set_operation") writer.write(keywordText(node.nameKw.text, ctx));
else if (node.type === "limit_clause") printLimitClause(writer, node, ctx);
else emitText(writer, ctx.renderers.block([node]).trim(), writer.column);
}
/** Emits a whole statement, one clause per line unless the statement collapses. */
function printStatement(statement: StatementNode, ctx: SqlLayoutContext): string | null {
const collapsed = renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth);
if (collapsed) return statement.hasSemicolon ? `${collapsed};` : collapsed;
if (isCreateTable(statement)) return createTableLayout(statement, ctx);
// A statement without clauses is a bare expression — a fragment the user
// selected in the editor — and has no clause layout to apply. Declining hands
// the whole input to sql-formatter, which formats such a fragment operand by
// operand; emitting it clause by clause would put every token on its own line.
if (!statement.children.some(isBodyNode)) return null;
const writer = new Writer(ctx.options);
let first = true;
for (const node of statement.children) {
if (!first) writer.newline(0);
writeBodyNode(writer, node, 0, ctx);
first = false;
}
if (statement.hasSemicolon) writer.write(";");
return writer.toString();
}
/**
* Formats already-parsed statements using the default style's layout rules.
*
* Returns `null` when any statement lies outside the model these rules describe,
* so the caller can format the input as a whole with sql-formatter instead of
* mixing two styles inside one script.
*/
export function printSqlLayout(statements: StatementNode[], ctx: SqlLayoutContext): string | null {
// Same convention as sql-formatter: the setting counts blank lines, so the
// separator is one newline longer.
const separator = "\n".repeat(ctx.options.linesBetweenQueries + 1);
const rendered: string[] = [];
for (const statement of statements) {
const text = printStatement(statement, ctx);
if (text === null) return null;
rendered.push(text);
}
return rendered.join(separator);
}
+39 -4
View File
@@ -1,4 +1,5 @@
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";
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "oracle" | "clickhouse" | "dameng" | "duckdb" | "generic";
@@ -361,10 +362,11 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
},
}
: options;
const resolvedDialect = dialect === "clickhouse" ? resolveClickHouseIdentifierSafeDialect(sqlFormatter) : undefined;
const formatWithFallback = (input: string): string => {
try {
if (dialect === "clickhouse") {
return formatDialect(input, { ...formatterOptions, dialect: resolveClickHouseIdentifierSafeDialect(sqlFormatter) });
if (resolvedDialect) {
return formatDialect(input, { ...formatterOptions, dialect: resolvedDialect });
}
return format(input, { language, ...formatterOptions });
} catch (err) {
@@ -389,14 +391,35 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
return emptyLineProtection ? restoreProtectedEmptyLines(laidOut, emptyLineProtection.markers) : laidOut;
};
// DBX's own layout printer produces the default style. It needs the AST and
// sql-formatter's internal layout machinery, so it can decline an input — an
// unparseable statement, an internal shape that moved. `null` means "use the
// public formatter", which is also what the tabular indent styles ask for:
// those are an alternative layout this printer deliberately does not reproduce.
const layoutOptions: Partial<SqlLayoutOptions> = {
lineWidth: normalizedSettings.expressionWidth,
indentWidth: normalizedSettings.tabWidth,
useTabs: normalizedSettings.useTabs,
linesBetweenQueries: normalizedSettings.linesBetweenQueries,
fromClauseSourceOnSameLine: normalizedSettings.fromClauseLayout === "sameLine",
keywordCase: normalizedSettings.keywordCase,
logicalOperatorNewline: normalizedSettings.logicalOperatorNewline,
};
const usesDefaultStyle = normalizedSettings.indentStyle === "standard";
const formatOnce = async (input: string): Promise<string> => {
const laidOut = usesDefaultStyle ? await formatSqlLayout({ sql: input, language, dialectOptions: resolvedDialect, cfg: formatterOptions, options: layoutOptions }) : null;
return laidOut ?? formatWithFallback(input);
};
try {
return finalizeFormattedSql(formatWithFallback(protectedInput.sql));
return finalizeFormattedSql(await formatOnce(protectedInput.sql));
} catch (err) {
const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(protectedInput.sql) : null;
if (!trailingDot) throw err;
try {
return finalizeFormattedSql(`${formatWithFallback(trailingDot.body)}${trailingDot.suffix}`);
return finalizeFormattedSql(`${await formatOnce(trailingDot.body)}${trailingDot.suffix}`);
} catch {
throw err;
}
@@ -640,6 +663,18 @@ function keepFromClauseAndFirstSourceOnSameLine(sql: string): string {
return lines.join("\n");
}
/**
* The text-level passes applied to whatever the formatter produced, whether the
* default style's layout printer or sql-formatter itself.
*
* The two settings-driven passes fix shapes the printer already gets right on
* its own — it keeps a `FROM` source on the keyword's line and joins operators
* for `none` — so they are no-ops on its output. They stay because a statement
* the printer declines falls back to sql-formatter's layout for the whole input,
* which does break those shapes; see `formatSqlText`. {@link
* normalizeLikeOperatorCase} is unrelated to either: it corrects a dialect quirk
* in both paths.
*/
function applySqlFormatterLayout(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string {
let formatted = normalizeLikeOperatorCase(sql, settings, dialect);
if (settings.logicalOperatorNewline === "none") formatted = keepLogicalOperatorsOnSameLine(formatted, dialect);
@@ -74,8 +74,8 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
useTabs: false,
tabWidth: 2,
logicalOperatorNewline: "before",
fromClauseLayout: "newLine",
expressionWidth: 50,
fromClauseLayout: "sameLine",
expressionWidth: 120,
linesBetweenQueries: 1,
preserveEmptyLines: false,
denseOperators: false,