sql_beautifier 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6df1f55c9242793101e9f562329763541d56aa4f17f7e3d81a12038ab5b53cca
4
- data.tar.gz: 43fea825ac0c4bded07098814a2ed67e5503e1829f3d47cc99a7c989ec0e69d1
3
+ metadata.gz: 74ce2777ba2e1a7635aa92623cbb6e252bef41abafa62fff7b3f9f651aad05af
4
+ data.tar.gz: 83f543391d4e60f2ab928eb44c6975d4f935b84c7e63bcbb05685331bcdf59a2
5
5
  SHA512:
6
- metadata.gz: d56c72c27d4b71781be0ee83ba44fb3a4e466567c5edbde267c2cfbeb245c81e78bc52a309d27e7dae69a2a7e3cbf1bb3ce55926a266a6c914e822140b6650b7
7
- data.tar.gz: 9e2e5118b5b12e52551300e2c0219c6cc42907fdb87fd56a8e6e80e193b212ae36ff87ee48d2a0135dc0140121d145c3f104622a0efbd0a3bbeffe10af378cea
6
+ metadata.gz: 3bbfe00044b32a468fb019a9d32010ce355803000d627d6b16146398b54515531d0a6b76a84022facaec39b1afb849307b08ee96b8859b00ad99f7f2895b2668
7
+ data.tar.gz: 2252b3c944667589b17c0bde79e2eca176ced558c6bf8d19795607490788dc468200a00a2dedbce667adee966956389b3957f70cc39fa317d865435ceee22007
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.5.0] - 2026-03-28
6
+
7
+ - Add support for Create Table As (CTA) formatting
8
+
9
+ ## [0.4.0] - 2026-03-27
10
+
11
+ - Add CTE (Common Table Expression) formatting with recursive indentation
12
+
5
13
  ## [0.3.0] - 2026-03-27
6
14
 
7
15
  - Add configuration system with `SqlBeautifier.configure` block and `SqlBeautifier.reset_configuration!`
@@ -4,7 +4,8 @@ module SqlBeautifier
4
4
  module ConditionFormatter
5
5
  module_function
6
6
 
7
- def format(text, indent_width:)
7
+ def format(text, args = {})
8
+ indent_width = args.fetch(:indent_width, 0)
8
9
  conditions = Tokenizer.split_top_level_conditions(text)
9
10
  return text.strip if conditions.length <= 1 && !parse_condition_group(conditions.dig(0, 1))
10
11
 
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ module CreateTableAsFormatter
5
+ MODIFIERS = %w[
6
+ temp
7
+ temporary
8
+ unlogged
9
+ local
10
+ ].freeze
11
+
12
+ WITH_DATA_SUFFIX_REGEX = %r{\s+(with\s+(?:no\s+)?data)\s*\z}i
13
+
14
+ module_function
15
+
16
+ def format(normalized_sql, _args = {})
17
+ return nil unless create_table_as_query?(normalized_sql)
18
+
19
+ parsed = parse(normalized_sql)
20
+ return nil unless parsed
21
+
22
+ format_statement(parsed[:preamble], parsed[:body], parsed[:suffix])
23
+ end
24
+
25
+ def create_table_as_query?(sql)
26
+ Tokenizer.keyword_at?(sql, 0, "create")
27
+ end
28
+
29
+ def parse(sql)
30
+ position = 0
31
+ return nil unless Tokenizer.keyword_at?(sql, position, "create")
32
+
33
+ position = skip_past_keyword(sql, position, "create")
34
+
35
+ modifier = detect_modifier(sql, position)
36
+ position = skip_past_keyword(sql, position, modifier) if modifier
37
+
38
+ return nil unless Tokenizer.keyword_at?(sql, position, "table")
39
+
40
+ position = skip_past_keyword(sql, position, "table")
41
+
42
+ if_not_exists = detect_if_not_exists?(sql, position)
43
+ position = skip_past_if_not_exists(sql, position) if if_not_exists
44
+
45
+ table_name, position = read_identifier(sql, position)
46
+ return nil unless table_name
47
+
48
+ position = skip_whitespace(sql, position)
49
+ return nil unless Tokenizer.keyword_at?(sql, position, "as")
50
+
51
+ position = skip_past_keyword(sql, position, "as")
52
+
53
+ result = extract_body(sql, position)
54
+ return nil unless result
55
+
56
+ body_sql, suffix = result
57
+ return nil unless body_sql
58
+
59
+ preamble = build_preamble(modifier, if_not_exists, table_name)
60
+ { preamble: preamble, body: body_sql, suffix: suffix }
61
+ end
62
+
63
+ def detect_modifier(sql, position)
64
+ MODIFIERS.detect { |modifier| Tokenizer.keyword_at?(sql, position, modifier) }
65
+ end
66
+
67
+ def detect_if_not_exists?(sql, position)
68
+ Tokenizer.keyword_at?(sql, position, "if") && Tokenizer.keyword_at?(sql, skip_past_keyword(sql, position, "if"), "not") && Tokenizer.keyword_at?(sql, skip_past_keyword(sql, skip_past_keyword(sql, position, "if"), "not"), "exists")
69
+ end
70
+
71
+ def skip_past_if_not_exists(sql, position)
72
+ position = skip_past_keyword(sql, position, "if")
73
+ position = skip_past_keyword(sql, position, "not")
74
+ skip_past_keyword(sql, position, "exists")
75
+ end
76
+
77
+ def extract_body(sql, position)
78
+ position = skip_whitespace(sql, position)
79
+ return nil if position >= sql.length
80
+
81
+ if sql[position] == Constants::OPEN_PARENTHESIS
82
+ closing = Tokenizer.find_matching_parenthesis(sql, position)
83
+ return nil unless closing
84
+
85
+ body = sql[(position + 1)...closing].strip
86
+ suffix = sql[(closing + 1)..].strip.presence
87
+ [body, suffix]
88
+ else
89
+ extract_unparenthesized_body(sql[position..].strip)
90
+ end
91
+ end
92
+
93
+ def extract_unparenthesized_body(raw_body)
94
+ return nil unless raw_body.present?
95
+
96
+ match = raw_body.match(WITH_DATA_SUFFIX_REGEX)
97
+
98
+ if match
99
+ body = raw_body[0...match.begin(0)].strip
100
+ return nil unless body.present?
101
+
102
+ [body, match[1]]
103
+ else
104
+ [raw_body, nil]
105
+ end
106
+ end
107
+
108
+ def build_preamble(modifier, if_not_exists, table_name)
109
+ parts = [Util.format_keyword("create")]
110
+ parts << Util.format_keyword(modifier) if modifier
111
+ parts << Util.format_keyword("table")
112
+ parts << "#{Util.format_keyword('if')} #{Util.format_keyword('not')} #{Util.format_keyword('exists')}" if if_not_exists
113
+ parts << Util.format_table_name(table_name)
114
+ parts << Util.format_keyword("as")
115
+ parts.join(" ")
116
+ end
117
+
118
+ def format_statement(preamble, body_sql, suffix)
119
+ indent_spaces = SqlBeautifier.config_for(:indent_spaces) || 4
120
+ formatted = Formatter.new(body_sql, depth: 0).call
121
+ return "#{preamble}\n" unless formatted
122
+
123
+ indentation = Util.whitespace(indent_spaces)
124
+ indented_lines = formatted.chomp.lines.map { |line| line.strip.empty? ? "\n" : "#{indentation}#{line}" }.join
125
+
126
+ formatted_suffix = suffix ? " #{format_suffix(suffix)}" : ""
127
+ "#{preamble} (\n#{indented_lines}\n)#{formatted_suffix}\n"
128
+ end
129
+
130
+ def format_suffix(suffix)
131
+ suffix.strip.split(%r{\s+}).map { |word| Util.format_keyword(word) }.join(" ")
132
+ end
133
+
134
+ def read_identifier(sql, position)
135
+ position = skip_whitespace(sql, position)
136
+ return nil if position >= sql.length
137
+
138
+ if sql[position] == Constants::DOUBLE_QUOTE
139
+ start = position
140
+ position += 1
141
+
142
+ while position < sql.length
143
+ if sql[position] == Constants::DOUBLE_QUOTE
144
+ if position + 1 < sql.length && sql[position + 1] == Constants::DOUBLE_QUOTE
145
+ position += 2
146
+ next
147
+ end
148
+
149
+ position += 1
150
+ break
151
+ end
152
+
153
+ position += 1
154
+ end
155
+
156
+ return nil unless position <= sql.length && sql[position - 1] == Constants::DOUBLE_QUOTE
157
+
158
+ return [sql[start...position], position]
159
+ end
160
+
161
+ start = position
162
+ position += 1 while position < sql.length && sql[position] =~ Tokenizer::IDENTIFIER_CHARACTER
163
+ return nil if position == start
164
+
165
+ [sql[start...position], position]
166
+ end
167
+
168
+ def skip_whitespace(sql, position)
169
+ position += 1 while position < sql.length && sql[position] =~ Constants::WHITESPACE_CHARACTER_REGEX
170
+ position
171
+ end
172
+
173
+ def skip_past_keyword(sql, position, keyword)
174
+ skip_whitespace(sql, position + keyword.length)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ module CteFormatter
5
+ module_function
6
+
7
+ def format(normalized_sql, args = {})
8
+ depth = args.fetch(:depth, 0)
9
+ return nil unless cte_query?(normalized_sql)
10
+
11
+ recursive, definitions, main_query_sql = parse(normalized_sql)
12
+ return nil unless definitions.any? && main_query_sql.present?
13
+
14
+ format_cte_statement(recursive, definitions, main_query_sql, depth)
15
+ end
16
+
17
+ def cte_query?(sql)
18
+ Tokenizer.keyword_at?(sql, 0, "with")
19
+ end
20
+
21
+ def parse(sql)
22
+ position = skip_past_keyword(sql, 0, "with")
23
+
24
+ recursive = Tokenizer.keyword_at?(sql, position, "recursive")
25
+ position = skip_past_keyword(sql, position, "recursive") if recursive
26
+
27
+ definitions = []
28
+
29
+ loop do
30
+ definition, new_position = parse_definition(sql, position)
31
+ break unless definition
32
+
33
+ definitions << definition
34
+ position = skip_whitespace(sql, new_position)
35
+
36
+ break unless position < sql.length && sql[position] == Constants::COMMA
37
+
38
+ position = skip_whitespace(sql, position + 1)
39
+ end
40
+
41
+ main_query_sql = sql[position..].strip
42
+
43
+ [recursive, definitions, main_query_sql]
44
+ end
45
+
46
+ def parse_definition(sql, position)
47
+ name, position = read_identifier(sql, position)
48
+ return nil unless name
49
+
50
+ position = skip_whitespace(sql, position)
51
+
52
+ column_list = parse_column_list(sql, position)
53
+ position = column_list[:next_position] if column_list
54
+
55
+ return nil unless Tokenizer.keyword_at?(sql, position, "as")
56
+
57
+ position = skip_past_keyword(sql, position, "as")
58
+ materialization, position = parse_materialization(sql, position)
59
+
60
+ return nil unless position < sql.length && sql[position] == Constants::OPEN_PARENTHESIS
61
+
62
+ closing = Tokenizer.find_matching_parenthesis(sql, position)
63
+ return nil unless closing
64
+
65
+ body_sql = sql[(position + 1)...closing].strip
66
+ definition = { name: name, body: body_sql }
67
+ definition[:column_list] = column_list[:text] if column_list
68
+ definition[:materialization] = materialization if materialization
69
+
70
+ [definition, closing + 1]
71
+ end
72
+
73
+ def parse_column_list(sql, position)
74
+ return nil unless position < sql.length && sql[position] == Constants::OPEN_PARENTHESIS
75
+
76
+ closing = Tokenizer.find_matching_parenthesis(sql, position)
77
+ return nil unless closing
78
+
79
+ after_paren = skip_whitespace(sql, closing + 1)
80
+ return nil unless Tokenizer.keyword_at?(sql, after_paren, "as")
81
+
82
+ { text: sql[(position + 1)...closing].strip, next_position: after_paren }
83
+ end
84
+
85
+ def format_cte_statement(recursive, definitions, main_query_sql, depth)
86
+ keyword_width = SqlBeautifier.config_for(:keyword_column_width)
87
+ cte_name_column = keyword_width
88
+ continuation_indent = Util.continuation_padding
89
+
90
+ output = +""
91
+
92
+ definitions.each_with_index do |definition, index|
93
+ if index.zero?
94
+ output << Util.keyword_padding("with")
95
+ output << "#{Util.format_keyword('recursive')} " if recursive
96
+ else
97
+ output << continuation_indent
98
+ end
99
+
100
+ output << definition_header(definition)
101
+ output << format_body(definition[:body], cte_name_column)
102
+ output << (index < definitions.length - 1 ? ",\n" : "\n\n")
103
+ end
104
+
105
+ formatted_main = Formatter.new(main_query_sql, depth: depth).call
106
+ output << formatted_main if formatted_main
107
+
108
+ output
109
+ end
110
+
111
+ def definition_header(definition)
112
+ header = +definition[:name].to_s
113
+ header << " (#{definition[:column_list]})" if definition[:column_list]
114
+ header << " #{Util.format_keyword('as')}"
115
+ header << " #{format_materialization(definition[:materialization])}" if definition[:materialization]
116
+ header << " "
117
+ header
118
+ end
119
+
120
+ def parse_materialization(sql, position)
121
+ position = skip_whitespace(sql, position)
122
+ return ["materialized", skip_past_keyword(sql, position, "materialized")] if Tokenizer.keyword_at?(sql, position, "materialized")
123
+ return [nil, position] unless Tokenizer.keyword_at?(sql, position, "not")
124
+
125
+ materialized_position = skip_past_keyword(sql, position, "not")
126
+ return [nil, position] unless Tokenizer.keyword_at?(sql, materialized_position, "materialized")
127
+
128
+ ["not materialized", skip_past_keyword(sql, materialized_position, "materialized")]
129
+ end
130
+
131
+ def format_materialization(materialization)
132
+ return Util.format_keyword("materialized") if materialization == "materialized"
133
+
134
+ [Util.format_keyword("not"), Util.format_keyword("materialized")].join(" ")
135
+ end
136
+
137
+ def format_body(body_sql, base_indent)
138
+ indent_spaces = SqlBeautifier.config_for(:indent_spaces) || 4
139
+ body_indent = base_indent + indent_spaces
140
+ formatted = Formatter.new(body_sql, depth: 0).call
141
+ return "(#{body_sql})" unless formatted
142
+
143
+ indentation = Util.whitespace(body_indent)
144
+ indented_lines = formatted.chomp.lines.map { |line| line.strip.empty? ? "\n" : "#{indentation}#{line}" }.join
145
+
146
+ "(\n#{indented_lines}\n#{Util.whitespace(base_indent)})"
147
+ end
148
+
149
+ def read_identifier(sql, position)
150
+ position = skip_whitespace(sql, position)
151
+ return nil if position >= sql.length
152
+
153
+ if sql[position] == Constants::DOUBLE_QUOTE
154
+ start = position
155
+ position += 1
156
+
157
+ while position < sql.length
158
+ if sql[position] == Constants::DOUBLE_QUOTE
159
+ if position + 1 < sql.length && sql[position + 1] == Constants::DOUBLE_QUOTE
160
+ position += 2
161
+ next
162
+ end
163
+
164
+ position += 1
165
+ break
166
+ end
167
+
168
+ position += 1
169
+ end
170
+
171
+ return nil unless position <= sql.length && sql[position - 1] == Constants::DOUBLE_QUOTE
172
+
173
+ return [sql[start...position], position]
174
+ end
175
+
176
+ start = position
177
+ position += 1 while position < sql.length && sql[position] =~ Tokenizer::IDENTIFIER_CHARACTER
178
+ return nil if position == start
179
+
180
+ [sql[start...position], position]
181
+ end
182
+
183
+ def skip_whitespace(sql, position)
184
+ position += 1 while position < sql.length && sql[position] =~ Constants::WHITESPACE_CHARACTER_REGEX
185
+ position
186
+ end
187
+
188
+ def skip_past_keyword(sql, position, keyword)
189
+ skip_whitespace(sql, position + keyword.length)
190
+ end
191
+ end
192
+ end
@@ -17,6 +17,12 @@ module SqlBeautifier
17
17
  @normalized_value = Normalizer.call(@value)
18
18
  return unless @normalized_value.present?
19
19
 
20
+ cte_result = CteFormatter.format(@normalized_value, depth: @depth)
21
+ return cte_result if cte_result
22
+
23
+ create_table_as_result = CreateTableAsFormatter.format(@normalized_value, depth: @depth)
24
+ return create_table_as_result if create_table_as_result
25
+
20
26
  first_clause_position = Tokenizer.first_clause_position(@normalized_value)
21
27
  return "#{@normalized_value}\n" if first_clause_position.nil? || first_clause_position.positive?
22
28
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqlBeautifier
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -12,6 +12,8 @@ require_relative "sql_beautifier/tokenizer"
12
12
  require_relative "sql_beautifier/table_registry"
13
13
  require_relative "sql_beautifier/condition_formatter"
14
14
  require_relative "sql_beautifier/subquery_formatter"
15
+ require_relative "sql_beautifier/cte_formatter"
16
+ require_relative "sql_beautifier/create_table_as_formatter"
15
17
  require_relative "sql_beautifier/clauses/base"
16
18
  require_relative "sql_beautifier/clauses/condition_clause"
17
19
  require_relative "sql_beautifier/clauses/select"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sql_beautifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kinnell Shah
@@ -49,6 +49,8 @@ files:
49
49
  - lib/sql_beautifier/condition_formatter.rb
50
50
  - lib/sql_beautifier/configuration.rb
51
51
  - lib/sql_beautifier/constants.rb
52
+ - lib/sql_beautifier/create_table_as_formatter.rb
53
+ - lib/sql_beautifier/cte_formatter.rb
52
54
  - lib/sql_beautifier/formatter.rb
53
55
  - lib/sql_beautifier/normalizer.rb
54
56
  - lib/sql_beautifier/subquery_formatter.rb