feat(formatter): add custom default SQL layout engine
This commit is contained in:
20 files changed
+1588
-175
No files matched your search
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user