Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [X.X.X] - YYYY-MM-DD

- Add `DROP TABLE` formatting — `DROP TABLE [IF EXISTS] table_name` statements are now recognized and rendered with proper keyword casing and PascalCase table names instead of passing through as normalized text
- Add `CREATE TABLE` (DDL) formatting — `CREATE [TEMP|TEMPORARY|UNLOGGED|LOCAL] TABLE [IF NOT EXISTS] table_name (column_defs)` statements with column definitions are now recognized and rendered with proper keyword casing and PascalCase table names
- Fix `INSERT INTO` with a single column rendering the column list on multiple lines — single-column lists now render inline (e.g. `insert into Table (id)` instead of expanding to three lines)
- Reject `DROP TABLE` statements with trailing text (e.g. `CASCADE`, `RESTRICT`, multiple table names) — these now return `nil` instead of silently dropping the trailing text
- Reject `CREATE TABLE` (DDL) statements with trailing clauses after column definitions (e.g. `WITH (...)`, `TABLESPACE ...`) — these now return `nil` instead of silently dropping the trailing text

## [0.10.2] - 2026-03-30

- Fix `INSERT INTO ... (columns) (SELECT ...)` not being recognized — `InsertQuery.parse_body` now unwraps parenthesized SELECT subqueries, supporting PostgreSQL's valid `INSERT INTO table (cols) (SELECT ...)` syntax
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,36 @@ where users.account_id = accounts.id
returning users.id;
```

### DROP TABLE

`DROP TABLE` statements are recognized and formatted with proper keyword casing and table name formatting:

```ruby
SqlBeautifier.call("DROP TABLE IF EXISTS persons")
```

Produces:

```sql
drop table if exists Persons;
```

### CREATE TABLE (DDL)

`CREATE TABLE` statements with column definitions are recognized and formatted with proper keyword casing and table name formatting. Column definitions are preserved as-is:

```ruby
SqlBeautifier.call("CREATE TEMPORARY TABLE persons (id bigint)")
```

Produces:

```sql
create temporary table Persons (id bigint);
```

Modifiers (`TEMP`, `TEMPORARY`, `UNLOGGED`, `LOCAL`) and `IF NOT EXISTS` are supported.

### Set Operators (UNION, INTERSECT, EXCEPT)

Compound queries joined by set operators are detected and each segment is formatted independently. The operator keyword appears on its own line with blank-line separation:
Expand Down
3 changes: 3 additions & 0 deletions lib/sql_beautifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
require_relative "sql_beautifier/condition"
require_relative "sql_beautifier/cte_definition"
require_relative "sql_beautifier/cte_query"
require_relative "sql_beautifier/drop_table"
require_relative "sql_beautifier/create_table_parsing"
require_relative "sql_beautifier/create_table"
require_relative "sql_beautifier/create_table_as"
require_relative "sql_beautifier/compound_query"
require_relative "sql_beautifier/dml_rendering"
Expand Down
7 changes: 7 additions & 0 deletions lib/sql_beautifier/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ module Constants
CLOSE_PARENTHESIS = ")"
COMMA = ","

TABLE_MODIFIERS = %w[
temp
temporary
unlogged
local
].freeze

LATERAL_PREFIX_PATTERN = %r{\Alateral\s+}i

WHITESPACE_REGEX = %r{\s+}
Expand Down
65 changes: 65 additions & 0 deletions lib/sql_beautifier/create_table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module SqlBeautifier
class CreateTable < Base
extend CreateTableParsing

option :modifier, default: -> {}
option :if_not_exists, type: Types::Bool
option :table_name
option :column_definitions

def self.parse(normalized_sql, **)
scanner = Scanner.new(normalized_sql)
return nil unless scanner.keyword_at?("create")

scanner.skip_past_keyword!("create")
modifier = detect_modifier(scanner)
scanner.skip_past_keyword!(modifier) if modifier

return nil unless scanner.keyword_at?("table")

scanner.skip_past_keyword!("table")

if_not_exists = detect_if_not_exists?(scanner)
skip_past_if_not_exists!(scanner) if if_not_exists

table_name = scanner.read_identifier!
return nil unless table_name

scanner.skip_whitespace!
return nil if scanner.finished?
Comment thread
kinnell marked this conversation as resolved.
return nil unless scanner.current_char == Constants::OPEN_PARENTHESIS

column_definitions = extract_column_definitions(normalized_sql, scanner)
return nil unless column_definitions

new(modifier: modifier, if_not_exists: if_not_exists, table_name: table_name, column_definitions: column_definitions)
Comment thread
kinnell marked this conversation as resolved.
end

def self.extract_column_definitions(normalized_sql, scanner)
closing = scanner.find_matching_parenthesis(scanner.position)
return nil unless closing

inner_text = normalized_sql[(scanner.position + 1)...closing].strip
return nil if inner_text.empty?

trailing_text = normalized_sql[(closing + 1)..].strip
return nil unless trailing_text.empty?

inner_text
end

private_class_method :extract_column_definitions

def render
parts = [Util.format_keyword("create")]
parts << Util.format_keyword(@modifier) if @modifier
parts << Util.format_keyword("table")
parts << "#{Util.format_keyword('if')} #{Util.format_keyword('not')} #{Util.format_keyword('exists')}" if @if_not_exists
parts << Util.format_table_name(@table_name)

"#{parts.join(' ')} (#{@column_definitions})\n"
end
end
end
28 changes: 1 addition & 27 deletions lib/sql_beautifier/create_table_as.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@

module SqlBeautifier
class CreateTableAs < Base
MODIFIERS = %w[
temp
temporary
unlogged
local
].freeze
extend CreateTableParsing

WITH_DATA_SUFFIX_REGEX = %r{\s+(with\s+(?:no\s+)?data)\s*\z}i

Expand Down Expand Up @@ -47,27 +42,6 @@ def self.parse(normalized_sql, depth: 0)
new(modifier: modifier, if_not_exists: if_not_exists, table_name: table_name, body_sql: body_sql, suffix: suffix, depth: depth)
end

def self.detect_modifier(scanner)
MODIFIERS.detect { |modifier| scanner.keyword_at?(modifier) }
end

def self.detect_if_not_exists?(scanner)
return false unless scanner.keyword_at?("if")

probe = Scanner.new(scanner.source, position: scanner.position)
probe.skip_past_keyword!("if")
return false unless probe.keyword_at?("not")

probe.skip_past_keyword!("not")
probe.keyword_at?("exists")
end

def self.skip_past_if_not_exists!(scanner)
scanner.skip_past_keyword!("if")
scanner.skip_past_keyword!("not")
scanner.skip_past_keyword!("exists")
end

def self.extract_body(sql, position)
scanner = Scanner.new(sql, position: position)
scanner.skip_whitespace!
Expand Down
32 changes: 32 additions & 0 deletions lib/sql_beautifier/create_table_parsing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module SqlBeautifier
module CreateTableParsing
def self.extended(base)
base.private_class_method :detect_modifier
base.private_class_method :detect_if_not_exists?
base.private_class_method :skip_past_if_not_exists!
end

def detect_modifier(scanner)
Constants::TABLE_MODIFIERS.detect { |modifier| scanner.keyword_at?(modifier) }
end

def detect_if_not_exists?(scanner)
return false unless scanner.keyword_at?("if")

probe = Scanner.new(scanner.source, position: scanner.position)
probe.skip_past_keyword!("if")
return false unless probe.keyword_at?("not")

probe.skip_past_keyword!("not")
probe.keyword_at?("exists")
end

def skip_past_if_not_exists!(scanner)
scanner.skip_past_keyword!("if")
scanner.skip_past_keyword!("not")
scanner.skip_past_keyword!("exists")
end
end
end
56 changes: 56 additions & 0 deletions lib/sql_beautifier/drop_table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module SqlBeautifier
class DropTable < Base
option :table_name
option :if_exists, type: Types::Bool

def self.parse(normalized_sql, **)
scanner = Scanner.new(normalized_sql)
return nil unless scanner.keyword_at?("drop")

scanner.skip_past_keyword!("drop")
return nil unless scanner.keyword_at?("table")

scanner.skip_past_keyword!("table")

if_exists = false

if scanner.keyword_at?("if")
return nil unless detect_if_exists?(scanner)

skip_past_if_exists!(scanner)
if_exists = true
end

table_name = scanner.read_identifier!
return nil unless table_name

Comment thread
kinnell marked this conversation as resolved.
scanner.skip_whitespace!
return nil unless scanner.finished?

new(table_name: table_name, if_exists: if_exists)
end

def self.detect_if_exists?(scanner)
probe = Scanner.new(scanner.source, position: scanner.position)
probe.skip_past_keyword!("if")
probe.keyword_at?("exists")
end

def self.skip_past_if_exists!(scanner)
scanner.skip_past_keyword!("if")
scanner.skip_past_keyword!("exists")
end

private_class_method :detect_if_exists?, :skip_past_if_exists!

def render
parts = [Util.format_keyword("drop"), Util.format_keyword("table")]
parts << "#{Util.format_keyword('if')} #{Util.format_keyword('exists')}" if @if_exists
parts << Util.format_table_name(@table_name)

"#{parts.join(' ')}\n"
end
end
end
6 changes: 6 additions & 0 deletions lib/sql_beautifier/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ def call
@leading_sentinels = extract_leading_sentinels!
return unless @normalized_value.present?

drop_table_result = DropTable.parse(@normalized_value)&.render
return prepend_sentinels(drop_table_result) if drop_table_result

cte_result = CteQuery.parse(@normalized_value, depth: @depth)&.render
return prepend_sentinels(cte_result) if cte_result

create_table_as_result = CreateTableAs.parse(@normalized_value, depth: @depth)&.render
return prepend_sentinels(create_table_as_result) if create_table_as_result

create_table_result = CreateTable.parse(@normalized_value)&.render
return prepend_sentinels(create_table_result) if create_table_result

compound_result = CompoundQuery.parse(@normalized_value, depth: @depth)&.render
return prepend_sentinels(compound_result) if compound_result

Expand Down
3 changes: 2 additions & 1 deletion lib/sql_beautifier/insert_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ def render_insert_into

def render_column_list
columns = Tokenizer.split_by_top_level_commas(@column_list)
indent = Util.whitespace(SqlBeautifier.config_for(:indent_spaces) || 4)
return " (#{columns.first.strip})" if columns.length == 1

indent = Util.whitespace(SqlBeautifier.config_for(:indent_spaces) || 4)
formatted_columns = columns.map { |column| "#{indent}#{column.strip}" }.join(",\n")

" (\n#{formatted_columns}\n)"
Expand Down
Loading
Loading