Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions crates/pgls_configuration/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ impl From<KeywordCase> for pgls_pretty_print::renderer::KeywordCase {
}
}

/// Where the body of a clause starts.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum ClauseBodyStyle {
#[default]
Break,
Compact,
}

impl FromStr for ClauseBodyStyle {
type Err = &'static str;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"break" => Ok(Self::Break),
"compact" => Ok(Self::Compact),
_ => Err("Value not supported for ClauseBodyStyle. Use 'break' or 'compact'."),
}
}
}

impl From<ClauseBodyStyle> for pgls_pretty_print::ClauseBodyStyle {
fn from(style: ClauseBodyStyle) -> Self {
match style {
ClauseBodyStyle::Break => Self::Break,
ClauseBodyStyle::Compact => Self::Compact,
}
}
}

/// The configuration for SQL formatting.
#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)]
#[partial(derive(Bpaf, Clone, Eq, PartialEq, Merge))]
Expand Down Expand Up @@ -97,6 +128,14 @@ pub struct FormatConfiguration {
/// Data type casing (text, varchar, int): "upper" or "lower". Default: "lower".
#[partial(bpaf(long("type-case")))]
pub type_case: KeywordCase,
/// Where the body of a clause starts: "break" for a new line, "compact" to keep the first
/// element on the keyword line. Default: "break".
#[partial(bpaf(long("clause-body-style")))]
pub clause_body_style: ClauseBodyStyle,
/// If `true`, the terminating semicolon goes on its own line when the statement spans several
/// lines. Default: `false`.
#[partial(bpaf(long("isolate-semicolon")))]
pub isolate_semicolon: bool,
/// If `true`, skip formatting of SQL function bodies (keep them verbatim). Default: `false`.
#[partial(bpaf(long("skip-fn-bodies")))]
pub skip_fn_bodies: bool,
Expand All @@ -118,6 +157,8 @@ impl Default for FormatConfiguration {
keyword_case: KeywordCase::default(),
constant_case: KeywordCase::default(),
type_case: KeywordCase::default(),
clause_body_style: ClauseBodyStyle::default(),
isolate_semicolon: false,
skip_fn_bodies: false,
ignore: Default::default(),
include: Default::default(),
Expand Down
21 changes: 18 additions & 3 deletions crates/pgls_pretty_print/src/emitter.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::FormatConfig;
pub use crate::codegen::group_kind::GroupKind;
pub use crate::codegen::token_kind::TokenKind;

Expand All @@ -22,14 +23,28 @@ pub enum LayoutEvent {
IndentEnd,
}

#[derive(Debug, Default)]
/// Collects layout events for the renderer.
///
/// The emitter holds the configuration because some options decide which tokens exist at all,
/// such as where a comma sits in a list, and not merely how a token is rendered.
#[derive(Debug)]
pub struct EventEmitter {
pub events: Vec<LayoutEvent>,
config: FormatConfig,
}

impl EventEmitter {
pub fn new() -> Self {
Self::default()
pub fn new(config: FormatConfig) -> Self {
Self {
events: Vec::new(),
config,
}
}

// Later option PRs inspect this while deciding which layout events to emit.
#[allow(dead_code)]
pub fn config(&self) -> &FormatConfig {
&self.config
}

pub fn token(&mut self, token: TokenKind) {
Expand Down
21 changes: 20 additions & 1 deletion crates/pgls_pretty_print/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ pub enum FormatError {
BetaUnsupported { message: String },
}

/// Where the body of a clause starts.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ClauseBodyStyle {
/// The body starts on the line after the keyword.
#[default]
Break,
/// The first element of the body stays on the keyword line: `FROM plan`.
Compact,
}

/// Configuration for the SQL formatter.
#[derive(Debug, Clone)]
pub struct FormatConfig {
Expand All @@ -46,6 +56,11 @@ pub struct FormatConfig {
pub constant_case: KeywordCase,
/// Casing for data types (text, varchar, int). Default: Lower.
pub type_case: KeywordCase,
/// Where the body of a clause starts. Default: Break.
pub clause_body_style: ClauseBodyStyle,
/// Put the terminating semicolon on its own line when the statement spans several lines.
/// Default: false.
pub isolate_semicolon: bool,
}

impl Default for FormatConfig {
Expand All @@ -57,6 +72,8 @@ impl Default for FormatConfig {
keyword_case: KeywordCase::default(),
constant_case: KeywordCase::default(),
type_case: KeywordCase::default(),
clause_body_style: ClauseBodyStyle::default(),
isolate_semicolon: false,
}
}
}
Expand All @@ -70,6 +87,7 @@ impl From<FormatConfig> for RenderConfig {
keyword_case: config.keyword_case,
constant_case: config.constant_case,
type_case: config.type_case,
isolate_semicolon: config.isolate_semicolon,
}
}
}
Expand Down Expand Up @@ -104,7 +122,7 @@ pub fn format_statement(
config: &FormatConfig,
) -> Result<FormatResult, FormatError> {
// Emit layout events from AST
let mut emitter = emitter::EventEmitter::new();
let mut emitter = emitter::EventEmitter::new(config.clone());
nodes::emit_node_enum(ast, &mut emitter);

// Render to string
Expand All @@ -115,6 +133,7 @@ pub fn format_statement(
keyword_case: config.keyword_case.clone(),
constant_case: config.constant_case.clone(),
type_case: config.type_case.clone(),
isolate_semicolon: config.isolate_semicolon,
};

let mut output = String::new();
Expand Down
9 changes: 8 additions & 1 deletion crates/pgls_pretty_print/src/nodes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,9 +534,16 @@ pub fn emit_node(node: &Node, e: &mut EventEmitter) {
}

pub(super) fn emit_clause_condition(e: &mut EventEmitter, clause: &Node) {
use crate::ClauseBodyStyle;
use crate::emitter::LineType;

e.line(LineType::SoftOrSpace);
// Compact keeps the condition on the keyword line. The indent stays in both cases so that the
// condition's own continuation lines, the second AND of a chain for instance, sit under it.
match e.config().clause_body_style {
ClauseBodyStyle::Compact => e.space(),
ClauseBodyStyle::Break => e.line(LineType::SoftOrSpace),
}

e.indent_start();
emit_node(clause, e);
e.indent_end();
Expand Down
15 changes: 12 additions & 3 deletions crates/pgls_pretty_print/src/nodes/select_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use pgls_query::{
protobuf::{LimitOption, SelectStmt, SetOperation},
};

use crate::TokenKind;
use crate::emitter::{EventEmitter, GroupKind, LineType};
use crate::{ClauseBodyStyle, TokenKind};

use super::{
node_list::emit_comma_separated_list, string::emit_keyword, window_def::emit_window_definition,
Expand Down Expand Up @@ -204,7 +204,13 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b
if !n.from_clause.is_empty() {
e.line(LineType::SoftOrSpace);
e.token(TokenKind::FROM_KW);
e.line(LineType::SoftOrSpace);

// Compact keeps the first relation on the FROM line; the joins that follow still
// break onto their own indented lines.
match e.config().clause_body_style {
ClauseBodyStyle::Compact => e.space(),
ClauseBodyStyle::Break => e.line(LineType::SoftOrSpace),
}

e.indent_start();

Expand Down Expand Up @@ -247,7 +253,10 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b
if !n.window_clause.is_empty() {
e.line(LineType::SoftOrSpace);
e.token(TokenKind::WINDOW_KW);
e.line(LineType::SoftOrSpace);
match e.config().clause_body_style {
ClauseBodyStyle::Compact => e.space(),
ClauseBodyStyle::Break => e.line(LineType::SoftOrSpace),
}
e.indent_start();
for (idx, window) in n.window_clause.iter().enumerate() {
if idx > 0 {
Expand Down
60 changes: 54 additions & 6 deletions crates/pgls_pretty_print/src/renderer.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::TokenKind;
use crate::emitter::{LayoutEvent, LineType};
use std::fmt::Write;

Expand Down Expand Up @@ -25,6 +26,8 @@ pub struct RenderConfig {
pub constant_case: KeywordCase,
/// Casing for data types (text, varchar, int, etc.)
pub type_case: KeywordCase,
/// Put the terminating semicolon on its own line when the statement spans several lines.
pub isolate_semicolon: bool,
}

impl Default for RenderConfig {
Expand All @@ -36,6 +39,7 @@ impl Default for RenderConfig {
keyword_case: KeywordCase::default(),
constant_case: KeywordCase::default(),
type_case: KeywordCase::default(),
isolate_semicolon: false,
}
}
}
Expand Down Expand Up @@ -140,6 +144,14 @@ impl<W: Write> Renderer<W> {
while i < events.len() {
match &events[i] {
LayoutEvent::Token(token) => {
// This path is only taken when the enclosing group broke, so the statement
// already spans several lines and the terminator can stand alone. A statement
// that fits is rendered by try_single_line, which never reaches this code, and
// therefore keeps its semicolon attached.
if self.config.isolate_semicolon && matches!(token, TokenKind::SEMICOLON) {
self.write_line_break()?;
}

let text = token.render(&self.config);
self.write_text(&text)?;
i += 1;
Expand Down Expand Up @@ -314,7 +326,7 @@ impl<W: Write> Renderer<W> {
mod tests {
use super::*;
use crate::codegen::token_kind::TokenKind;
use crate::emitter::EventEmitter;
use crate::emitter::{EventEmitter, GroupKind};

fn render_events(events: Vec<LayoutEvent>, config: RenderConfig) -> String {
let mut output = String::new();
Expand All @@ -325,7 +337,7 @@ mod tests {

#[test]
fn test_keyword_case_upper() {
let mut emitter = EventEmitter::new();
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.token(TokenKind::SELECT_KW);
emitter.space();
emitter.token(TokenKind::INT_NUMBER(1));
Expand All @@ -342,7 +354,7 @@ mod tests {

#[test]
fn test_keyword_case_lower() {
let mut emitter = EventEmitter::new();
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.token(TokenKind::SELECT_KW);
emitter.space();
emitter.token(TokenKind::INT_NUMBER(1));
Expand All @@ -359,7 +371,7 @@ mod tests {

#[test]
fn test_constant_case_upper() {
let mut emitter = EventEmitter::new();
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.token(TokenKind::SELECT_KW);
emitter.space();
emitter.token(TokenKind::NULL);
Expand All @@ -382,7 +394,7 @@ mod tests {

#[test]
fn test_constant_case_lower() {
let mut emitter = EventEmitter::new();
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.token(TokenKind::SELECT_KW);
emitter.space();
emitter.token(TokenKind::NULL);
Expand All @@ -402,7 +414,7 @@ mod tests {

#[test]
fn test_mixed_case_settings() {
let mut emitter = EventEmitter::new();
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.token(TokenKind::SELECT_KW);
emitter.space();
emitter.token(TokenKind::INT_NUMBER(1));
Expand Down Expand Up @@ -435,4 +447,40 @@ mod tests {
let output = render_events(emitter.events, config);
assert_eq!(output, "SELECT 1 WHERE name IS NOT null AND active = true");
}

#[test]
fn a_broken_statement_gets_its_semicolon_on_its_own_line() {
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.group_start(GroupKind::SelectStmt);
emitter.token(TokenKind::SELECT_KW);
emitter.line(crate::emitter::LineType::Hard);
emitter.token(TokenKind::INT_NUMBER(1));
emitter.token(TokenKind::SEMICOLON);
emitter.group_end();

let config = RenderConfig {
isolate_semicolon: true,
..Default::default()
};

let output = render_events(emitter.events, config);
assert_eq!(output, "select\n1\n;");
}

#[test]
fn a_single_line_statement_keeps_its_semicolon_attached() {
let mut emitter = EventEmitter::new(crate::FormatConfig::default());
emitter.token(TokenKind::SELECT_KW);
emitter.space();
emitter.token(TokenKind::INT_NUMBER(1));
emitter.token(TokenKind::SEMICOLON);

let config = RenderConfig {
isolate_semicolon: true,
..Default::default()
};

let output = render_events(emitter.events, config);
assert_eq!(output, "select 1;");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- pgls-format: clauseBodyStyle=compact, indentStyle=tabs, indentSize=4, lineWidth=80
SELECT staging.buildings.id
FROM staging.buildings
WHERE staging.buildings.construction_year > 1950
AND staging.buildings.address_fk IS NOT NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- pgls-format: clauseBodyStyle=compact, indentStyle=tabs, indentSize=4, lineWidth=80
SELECT
staging.buildings.id,
staging.addresses.city
FROM staging.buildings
LEFT JOIN staging.addresses ON staging.addresses.id = staging.buildings.address_fk
WHERE staging.buildings.construction_year > 1950;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- pgls-format: keywordCase=upper, indentStyle=tabs, indentSize=4, lineWidth=80
SELECT
t.a,
t.b
FROM s.t
WHERE t.a > 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- pgls-format: isolateSemicolon=true, indentStyle=tabs, indentSize=4, lineWidth=80
SELECT
staging.buildings.id,
staging.buildings.construction_year,
staging.buildings.address_fk
FROM staging.buildings;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: crates/pgls_pretty_print/tests/tests.rs
input_file: crates/pgls_pretty_print/tests/data/single/compact_clause_condition.sql
---
select
staging.buildings.id
from staging.buildings
where staging.buildings.construction_year >
1950 and
staging.buildings.address_fk is not null;
Loading