sql_beautifier 0.2.0 → 0.4.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: d52933edcc70d3aaa5ed46f2c5d3bc6ac0a285294d84b25aa95c4e397fcb1d79
4
- data.tar.gz: 5411d9f654c2d73fad75289e15433f47c34a45f463eae15fb1d832306fb62bd6
3
+ metadata.gz: 928801124a1241f4b12dc2e7e7966cd95dcba5754cc6ee88a08c9f641f2e32e4
4
+ data.tar.gz: eb1db005fb6923f5f99aaacc44caeeed14b6c7f6e4d8cd174fb3fcff92cf944e
5
5
  SHA512:
6
- metadata.gz: bbb3edc9cbe4e663ee167fda3632b12ffff04e5eddc6f225728abad772be643c7c6c97755ca4662210b380489ab5c2acf39968457fbc49042079e39eec5b120a
7
- data.tar.gz: 5822d3514ad54619848b8e90cce8d5c4989cd2d367a20df00d51d3c03bf07a34b55166757ae72694970583a28db840a3234d7a309520673d2afc8e0d6a98e6f8
6
+ metadata.gz: c0ee8ca9569e7409f989cce52070112bb61af2c064dec679a96e7f26bb74837b8f2ae207617f619280140548fb5cc8804a1996464265415efc96d0e0fafc7085
7
+ data.tar.gz: b1cff6570a5137fd7ff26fd11eed662d7002c9d242ca03ea303c0c1711f4a44f12dbdc3958ce3657b0988742abd351792476ab0fc7171ccafebe1c28a70be595
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.4.0] - 2026-03-27
6
+
7
+ ## [0.3.0] - 2026-03-27
8
+
9
+ - Add configuration system with `SqlBeautifier.configure` block and `SqlBeautifier.reset_configuration!`
10
+ - Add configurable keyword case (`:lower` / `:upper`), keyword column width, indent spaces, table name format (`:pascal_case` / `:lowercase`), inline group threshold, and alias strategy (`:initials` / `:none` / callable)
11
+ - Add semicolon stripping in normalizer (trailing `;` removed before formatting)
12
+ - Add comment stripping in normalizer (`--` line comments and `/* */` block comments, string-aware)
13
+ - Add subquery formatting with recursive indentation (`(select ...)` expanded to multiline)
14
+
5
15
  ## [0.2.0] - 2026-03-27
6
16
 
7
17
  - Add JOIN support (inner, left, right, full outer, cross) with formatted continuation lines
data/README.md CHANGED
@@ -48,16 +48,16 @@ where active = true
48
48
  order by name
49
49
  ```
50
50
 
51
- Single-word keywords are lowercased and padded so their clause bodies start at an 8-character column. Multi-word clauses such as `order by` and `group by`, and short clauses like `limit`, use a single space between the keyword and the clause body instead of padding. Each clause is separated by a blank line. Multi-column SELECT lists place each column on its own line with continuation indentation. Table names are PascalCased and automatically aliased.
51
+ Single-word keywords are lowercased and padded so their clause bodies start at an 8-character column. Multi-word clauses such as `order by` and `group by`, and short clauses like `limit`, use a single space between the keyword and the clause body instead of padding. Clause spacing is compact by default for simple one-column / one-table / one-condition queries, and otherwise uses blank lines between top-level clauses. Multi-column SELECT lists place each column on its own line with continuation indentation. Table names are PascalCased and automatically aliased.
52
52
 
53
53
  ### Table Aliasing
54
54
 
55
55
  Tables are automatically aliased using their initials. Underscore-separated table names use the first letter of each segment:
56
56
 
57
- | Table Name | PascalCase | Alias |
58
- | --- | --- | --- |
59
- | `users` | `Users` | `u` |
60
- | `active_storage_blobs` | `Active_Storage_Blobs` | `asb` |
57
+ | Table Name | PascalCase | Alias |
58
+ | -------------------------- | -------------------------- | ----- |
59
+ | `users` | `Users` | `u` |
60
+ | `active_storage_blobs` | `Active_Storage_Blobs` | `asb` |
61
61
  | `person_event_invitations` | `Person_Event_Invitations` | `pei` |
62
62
 
63
63
  All `table.column` references throughout the query are replaced with `alias.column`:
@@ -219,11 +219,8 @@ Produces:
219
219
 
220
220
  ```sql
221
221
  select id
222
-
223
222
  from Users u
224
-
225
223
  order by created_at desc
226
-
227
224
  limit 25
228
225
  ```
229
226
 
@@ -263,6 +260,87 @@ select user_id,
263
260
  from Users u
264
261
  ```
265
262
 
263
+ ### Subqueries
264
+
265
+ Subqueries are automatically detected and recursively formatted with indentation:
266
+
267
+ ```ruby
268
+ SqlBeautifier.call("SELECT id FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100)")
269
+ ```
270
+
271
+ Produces:
272
+
273
+ ```sql
274
+ select id
275
+ from Users u
276
+ where id in (
277
+ select user_id
278
+ from Orders o
279
+ where total > 100
280
+ )
281
+ ```
282
+
283
+ Nested subqueries increase indentation at each level.
284
+
285
+ ### Comments and Semicolons
286
+
287
+ SQL comments (`--` line comments and `/* */` block comments) and trailing semicolons are automatically stripped during normalization. Comments inside string literals are preserved:
288
+
289
+ ```ruby
290
+ SqlBeautifier.call("SELECT id /* primary key */ FROM users -- main table\nWHERE active = true;")
291
+ ```
292
+
293
+ Produces:
294
+
295
+ ```sql
296
+ select id
297
+ from Users u
298
+ where active = true
299
+ ```
300
+
301
+ ### Configuration
302
+
303
+ Customize formatting behavior with `SqlBeautifier.configure`:
304
+
305
+ ```ruby
306
+ SqlBeautifier.configure do |config|
307
+ config.keyword_case = :upper # :lower (default), :upper
308
+ config.keyword_column_width = 10 # default: 8
309
+ config.indent_spaces = 4 # default: 4
310
+ config.clause_spacing_mode = :spacious # :compact (default), :spacious
311
+ config.table_name_format = :lowercase # :pascal_case (default), :lowercase
312
+ config.inline_group_threshold = 80 # default: 100
313
+ config.alias_strategy = :none # :initials (default), :none, or a callable
314
+ end
315
+ ```
316
+
317
+ #### Clause Spacing Modes
318
+
319
+ - `:compact` (default) keeps top-level clauses on single newlines only when the query is simple:
320
+ - exactly one SELECT column
321
+ - exactly one FROM table (no JOINs)
322
+ - zero or one top-level WHERE condition
323
+ - only `select`, `from`, optional `where`, optional `order by`, and optional `limit`
324
+ - `:spacious` always separates top-level clauses with blank lines
325
+
326
+ Reset to defaults:
327
+
328
+ ```ruby
329
+ SqlBeautifier.reset_configuration!
330
+ ```
331
+
332
+ #### Alias Strategies
333
+
334
+ - `:initials` (default) — automatic aliases using table initials (`users` → `u`, `active_storage_blobs` → `asb`)
335
+ - `:none` — no automatic aliases (explicit aliases in the SQL are still preserved)
336
+ - Callable — provide a proc/lambda for custom alias generation:
337
+
338
+ ```ruby
339
+ SqlBeautifier.configure do |config|
340
+ config.alias_strategy = ->(table_name) { "t_#{table_name[0..2]}" }
341
+ end
342
+ ```
343
+
266
344
  ### Callable Interface
267
345
 
268
346
  `SqlBeautifier.call` is the public API, making it a valid callable for Rails `normalizes` and anywhere a proc-like object is expected:
@@ -10,6 +10,16 @@ module SqlBeautifier
10
10
  def initialize(value)
11
11
  @value = value
12
12
  end
13
+
14
+ private
15
+
16
+ def keyword_prefix
17
+ Util.keyword_padding(self.class::KEYWORD)
18
+ end
19
+
20
+ def continuation_indent
21
+ Util.continuation_padding
22
+ end
13
23
  end
14
24
  end
15
25
  end
@@ -4,10 +4,10 @@ module SqlBeautifier
4
4
  module Clauses
5
5
  class ConditionClause < Base
6
6
  def call
7
- return "#{self.class::KEYWORD_PREFIX}#{@value.strip}" unless multiple_conditions?
7
+ return "#{keyword_prefix}#{@value.strip}" unless multiple_conditions?
8
8
 
9
- formatted_conditions = ConditionFormatter.format(@value, indent_width: Constants::KEYWORD_COLUMN_WIDTH)
10
- formatted_conditions.sub(Constants::LEADING_KEYWORD_INDENT_PATTERN, self.class::KEYWORD_PREFIX)
9
+ formatted_conditions = ConditionFormatter.format(@value, indent_width: SqlBeautifier.config_for(:keyword_column_width))
10
+ formatted_conditions.sub(continuation_indent, keyword_prefix)
11
11
  end
12
12
 
13
13
  private
@@ -3,9 +3,7 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class From < Base
6
- KEYWORD_PREFIX = "from "
7
- CONTINUATION_INDENTATION = " "
8
- JOIN_CONDITION_INDENTATION = " "
6
+ KEYWORD = "from"
9
7
 
10
8
  def self.call(value, table_registry:)
11
9
  new(value, table_registry: table_registry).call
@@ -22,7 +20,7 @@ module SqlBeautifier
22
20
  join_parts = split_join_parts
23
21
  primary_table_text = join_parts.shift.strip
24
22
  formatted_primary_table_name = format_table_with_alias(primary_table_text)
25
- add_line!("#{KEYWORD_PREFIX}#{formatted_primary_table_name}")
23
+ add_line!("#{keyword_prefix}#{formatted_primary_table_name}")
26
24
 
27
25
  join_parts.each { |join_part| format_join_part(join_part) }
28
26
 
@@ -35,6 +33,10 @@ module SqlBeautifier
35
33
  @lines << line
36
34
  end
37
35
 
36
+ def join_condition_indentation
37
+ Util.whitespace(SqlBeautifier.config_for(:keyword_column_width) + 4)
38
+ end
39
+
38
40
  def format_join_part(join_part)
39
41
  join_keyword, remaining_join_content = extract_join_keyword(join_part)
40
42
  return unless join_keyword && remaining_join_content
@@ -45,7 +47,7 @@ module SqlBeautifier
45
47
  format_join_with_conditions(join_keyword, remaining_join_content, on_keyword_position)
46
48
  else
47
49
  formatted_table_name = format_table_with_alias(remaining_join_content)
48
- add_line!("#{CONTINUATION_INDENTATION}#{join_keyword} #{formatted_table_name}")
50
+ add_line!("#{continuation_indent}#{join_keyword} #{formatted_table_name}")
49
51
  end
50
52
  end
51
53
 
@@ -56,10 +58,10 @@ module SqlBeautifier
56
58
 
57
59
  formatted_table_name = format_table_with_alias(table_text)
58
60
  first_condition = on_conditions.first[1]
59
- add_line!("#{CONTINUATION_INDENTATION}#{join_keyword} #{formatted_table_name} on #{first_condition}")
61
+ add_line!("#{continuation_indent}#{join_keyword} #{formatted_table_name} on #{first_condition}")
60
62
 
61
63
  on_conditions.drop(1).each do |conjunction, additional_condition|
62
- add_line!("#{JOIN_CONDITION_INDENTATION}#{conjunction} #{additional_condition}")
64
+ add_line!("#{join_condition_indentation}#{conjunction} #{additional_condition}")
63
65
  end
64
66
  end
65
67
 
@@ -137,7 +139,7 @@ module SqlBeautifier
137
139
 
138
140
  def format_table_with_alias(table_text)
139
141
  table_name = Util.first_word(table_text)
140
- formatted_table_name = Util.upper_pascal_case(table_name)
142
+ formatted_table_name = Util.format_table_name(table_name)
141
143
  table_alias = @table_registry.alias_for(table_name)
142
144
  return formatted_table_name unless table_alias
143
145
 
@@ -3,10 +3,10 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class GroupBy < Base
6
- KEYWORD_PREFIX = "group by "
6
+ KEYWORD = "group by"
7
7
 
8
8
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
9
+ "#{keyword_prefix}#{@value.strip}"
10
10
  end
11
11
  end
12
12
  end
@@ -3,7 +3,7 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Having < ConditionClause
6
- KEYWORD_PREFIX = "having "
6
+ KEYWORD = "having"
7
7
  end
8
8
  end
9
9
  end
@@ -3,10 +3,16 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Limit < Base
6
- KEYWORD_PREFIX = "limit "
6
+ KEYWORD = "limit"
7
7
 
8
8
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
9
+ "#{keyword_prefix}#{@value.strip}"
10
+ end
11
+
12
+ private
13
+
14
+ def keyword_prefix
15
+ "#{Util.format_keyword(KEYWORD)} "
10
16
  end
11
17
  end
12
18
  end
@@ -3,10 +3,10 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class OrderBy < Base
6
- KEYWORD_PREFIX = "order by "
6
+ KEYWORD = "order by"
7
7
 
8
8
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
9
+ "#{keyword_prefix}#{@value.strip}"
10
10
  end
11
11
  end
12
12
  end
@@ -3,8 +3,7 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Select < Base
6
- KEYWORD_PREFIX = "select "
7
- CONTINUATION_INDENTATION = " "
6
+ KEYWORD = "select"
8
7
  DISTINCT_ON_PARENTHESIS_PATTERN = %r{distinct on\s*\(}
9
8
  DISTINCT_ON_PATTERN = %r{distinct on }
10
9
  LEADING_COMMA_PATTERN = %r{\A,\s*}
@@ -22,15 +21,15 @@ module SqlBeautifier
22
21
  private
23
22
 
24
23
  def keyword_line(column)
25
- "#{KEYWORD_PREFIX}#{column.strip}"
24
+ "#{keyword_prefix}#{column.strip}"
26
25
  end
27
26
 
28
27
  def continuation_line(column)
29
- "#{CONTINUATION_INDENTATION}#{column.strip}"
28
+ "#{continuation_indent}#{column.strip}"
30
29
  end
31
30
 
32
31
  def format_with_prefix(prefix, columns)
33
- first_line = "#{KEYWORD_PREFIX}#{prefix}"
32
+ first_line = "#{keyword_prefix}#{prefix}"
34
33
  column_lines = columns.map { |column| continuation_line(column) }
35
34
 
36
35
  "#{first_line}\n#{column_lines.join(",\n")}"
@@ -3,7 +3,7 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Where < ConditionClause
6
- KEYWORD_PREFIX = "where "
6
+ KEYWORD = "where"
7
7
  end
8
8
  end
9
9
  end
@@ -9,7 +9,7 @@ module SqlBeautifier
9
9
  return text.strip if conditions.length <= 1 && !parse_condition_group(conditions.dig(0, 1))
10
10
 
11
11
  conditions = flatten_same_conjunction_groups(conditions)
12
- indentation = " " * indent_width
12
+ indentation = Util.whitespace(indent_width)
13
13
  lines = []
14
14
 
15
15
  conditions.each_with_index do |(conjunction, condition_text), index|
@@ -92,11 +92,11 @@ module SqlBeautifier
92
92
  return condition_text unless inner_conditions
93
93
 
94
94
  inline_version = rebuild_inline(inner_conditions)
95
- return inline_version if inline_version.length <= Constants::INLINE_GROUP_THRESHOLD
95
+ return inline_version if inline_version.length <= SqlBeautifier.config_for(:inline_group_threshold)
96
96
 
97
97
  inner_content = Util.strip_outer_parentheses(condition_text.strip)
98
98
  formatted_inner_content = format(inner_content, indent_width: indent_width + 4)
99
- indentation = " " * indent_width
99
+ indentation = Util.whitespace(indent_width)
100
100
 
101
101
  "(\n#{formatted_inner_content}\n#{indentation})"
102
102
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ class Configuration
5
+ DEFAULTS = {
6
+ keyword_case: :lower,
7
+ keyword_column_width: 8,
8
+ indent_spaces: 4,
9
+ clause_spacing_mode: :compact,
10
+ table_name_format: :pascal_case,
11
+ inline_group_threshold: 100,
12
+ alias_strategy: :initials,
13
+ }.freeze
14
+
15
+ attr_accessor :keyword_case
16
+ attr_accessor :keyword_column_width
17
+ attr_accessor :indent_spaces
18
+ attr_accessor :clause_spacing_mode
19
+ attr_accessor :table_name_format
20
+ attr_accessor :inline_group_threshold
21
+ attr_accessor :alias_strategy
22
+
23
+ def initialize
24
+ reset!
25
+ end
26
+
27
+ def reset!
28
+ DEFAULTS.each do |key, value|
29
+ public_send(:"#{key}=", value)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -29,11 +29,6 @@ module SqlBeautifier
29
29
  CONJUNCTIONS = %w[and or].freeze
30
30
  BETWEEN_KEYWORD = "between"
31
31
 
32
- INLINE_GROUP_THRESHOLD = 100
33
- KEYWORD_COLUMN_WIDTH = 8
34
-
35
- LEADING_KEYWORD_INDENT_PATTERN = %r{\A#{' ' * KEYWORD_COLUMN_WIDTH}}
36
-
37
32
  OPEN_PARENTHESIS = "("
38
33
  CLOSE_PARENTHESIS = ")"
39
34
  COMMA = ","
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ module CteFormatter
5
+ module_function
6
+
7
+ def format(normalized_sql, depth: 0)
8
+ return nil unless cte_query?(normalized_sql)
9
+
10
+ recursive, definitions, main_query_sql = parse(normalized_sql)
11
+ return nil unless definitions.any? && main_query_sql.present?
12
+
13
+ format_cte_statement(recursive, definitions, main_query_sql, depth)
14
+ end
15
+
16
+ def cte_query?(sql)
17
+ Tokenizer.keyword_at?(sql, 0, "with")
18
+ end
19
+
20
+ def parse(sql)
21
+ position = skip_past_keyword(sql, 0, "with")
22
+
23
+ recursive = Tokenizer.keyword_at?(sql, position, "recursive")
24
+ position = skip_past_keyword(sql, position, "recursive") if recursive
25
+
26
+ definitions = []
27
+
28
+ loop do
29
+ definition, new_position = parse_definition(sql, position)
30
+ break unless definition
31
+
32
+ definitions << definition
33
+ position = skip_whitespace(sql, new_position)
34
+
35
+ break unless position < sql.length && sql[position] == Constants::COMMA
36
+
37
+ position = skip_whitespace(sql, position + 1)
38
+ end
39
+
40
+ main_query_sql = sql[position..].strip
41
+
42
+ [recursive, definitions, main_query_sql]
43
+ end
44
+
45
+ def parse_definition(sql, position)
46
+ name, position = read_identifier(sql, position)
47
+ return nil unless name
48
+
49
+ position = skip_whitespace(sql, position)
50
+
51
+ column_list = parse_column_list(sql, position)
52
+ position = column_list[:next_position] if column_list
53
+
54
+ return nil unless Tokenizer.keyword_at?(sql, position, "as")
55
+
56
+ position = skip_past_keyword(sql, position, "as")
57
+ materialization, position = parse_materialization(sql, position)
58
+
59
+ return nil unless position < sql.length && sql[position] == Constants::OPEN_PARENTHESIS
60
+
61
+ closing = Tokenizer.find_matching_parenthesis(sql, position)
62
+ return nil unless closing
63
+
64
+ body_sql = sql[(position + 1)...closing].strip
65
+ definition = { name: name, body: body_sql }
66
+ definition[:column_list] = column_list[:text] if column_list
67
+ definition[:materialization] = materialization if materialization
68
+
69
+ [definition, closing + 1]
70
+ end
71
+
72
+ def parse_column_list(sql, position)
73
+ return nil unless position < sql.length && sql[position] == Constants::OPEN_PARENTHESIS
74
+
75
+ closing = Tokenizer.find_matching_parenthesis(sql, position)
76
+ return nil unless closing
77
+
78
+ after_paren = skip_whitespace(sql, closing + 1)
79
+ return nil unless Tokenizer.keyword_at?(sql, after_paren, "as")
80
+
81
+ { text: sql[(position + 1)...closing].strip, next_position: after_paren }
82
+ end
83
+
84
+ def format_cte_statement(recursive, definitions, main_query_sql, depth)
85
+ keyword_width = SqlBeautifier.config_for(:keyword_column_width)
86
+ cte_name_column = keyword_width
87
+ continuation_indent = Util.continuation_padding
88
+
89
+ output = +""
90
+
91
+ definitions.each_with_index do |definition, index|
92
+ if index.zero?
93
+ output << Util.keyword_padding("with")
94
+ output << "#{Util.format_keyword('recursive')} " if recursive
95
+ else
96
+ output << continuation_indent
97
+ end
98
+
99
+ output << definition_header(definition)
100
+ output << format_body(definition[:body], cte_name_column)
101
+ output << (index < definitions.length - 1 ? ",\n" : "\n\n")
102
+ end
103
+
104
+ formatted_main = Formatter.new(main_query_sql, depth: depth).call
105
+ output << formatted_main if formatted_main
106
+
107
+ output
108
+ end
109
+
110
+ def definition_header(definition)
111
+ header = +definition[:name].to_s
112
+ header << " (#{definition[:column_list]})" if definition[:column_list]
113
+ header << " #{Util.format_keyword('as')}"
114
+ header << " #{format_materialization(definition[:materialization])}" if definition[:materialization]
115
+ header << " "
116
+ header
117
+ end
118
+
119
+ def parse_materialization(sql, position)
120
+ position = skip_whitespace(sql, position)
121
+ return ["materialized", skip_past_keyword(sql, position, "materialized")] if Tokenizer.keyword_at?(sql, position, "materialized")
122
+ return [nil, position] unless Tokenizer.keyword_at?(sql, position, "not")
123
+
124
+ materialized_position = skip_past_keyword(sql, position, "not")
125
+ return [nil, position] unless Tokenizer.keyword_at?(sql, materialized_position, "materialized")
126
+
127
+ ["not materialized", skip_past_keyword(sql, materialized_position, "materialized")]
128
+ end
129
+
130
+ def format_materialization(materialization)
131
+ return Util.format_keyword("materialized") if materialization == "materialized"
132
+
133
+ [Util.format_keyword("not"), Util.format_keyword("materialized")].join(" ")
134
+ end
135
+
136
+ def format_body(body_sql, base_indent)
137
+ indent_spaces = SqlBeautifier.config_for(:indent_spaces) || 4
138
+ body_indent = base_indent + indent_spaces
139
+ formatted = Formatter.new(body_sql, depth: 0).call
140
+ return "(#{body_sql})" unless formatted
141
+
142
+ indentation = Util.whitespace(body_indent)
143
+ indented_lines = formatted.chomp.lines.map { |line| line.strip.empty? ? "\n" : "#{indentation}#{line}" }.join
144
+
145
+ "(\n#{indented_lines}\n#{Util.whitespace(base_indent)})"
146
+ end
147
+
148
+ def read_identifier(sql, position)
149
+ position = skip_whitespace(sql, position)
150
+ return nil if position >= sql.length
151
+
152
+ if sql[position] == Constants::DOUBLE_QUOTE
153
+ start = position
154
+ position += 1
155
+
156
+ while position < sql.length
157
+ if sql[position] == Constants::DOUBLE_QUOTE
158
+ if position + 1 < sql.length && sql[position + 1] == Constants::DOUBLE_QUOTE
159
+ position += 2
160
+ next
161
+ end
162
+
163
+ position += 1
164
+ break
165
+ end
166
+
167
+ position += 1
168
+ end
169
+
170
+ return nil unless position <= sql.length && sql[position - 1] == Constants::DOUBLE_QUOTE
171
+
172
+ return [sql[start...position], position]
173
+ end
174
+
175
+ start = position
176
+ position += 1 while position < sql.length && sql[position] =~ Tokenizer::IDENTIFIER_CHARACTER
177
+ return nil if position == start
178
+
179
+ [sql[start...position], position]
180
+ end
181
+
182
+ def skip_whitespace(sql, position)
183
+ position += 1 while position < sql.length && sql[position] =~ Constants::WHITESPACE_CHARACTER_REGEX
184
+ position
185
+ end
186
+
187
+ def skip_past_keyword(sql, position, keyword)
188
+ skip_whitespace(sql, position + keyword.length)
189
+ end
190
+ end
191
+ end
@@ -6,8 +6,9 @@ module SqlBeautifier
6
6
  new(value).call
7
7
  end
8
8
 
9
- def initialize(value)
9
+ def initialize(value, depth: 0)
10
10
  @value = value
11
+ @depth = depth
11
12
  end
12
13
 
13
14
  def call
@@ -16,6 +17,9 @@ module SqlBeautifier
16
17
  @normalized_value = Normalizer.call(@value)
17
18
  return unless @normalized_value.present?
18
19
 
20
+ cte_result = CteFormatter.format(@normalized_value, depth: @depth)
21
+ return cte_result if cte_result
22
+
19
23
  first_clause_position = Tokenizer.first_clause_position(@normalized_value)
20
24
  return "#{@normalized_value}\n" if first_clause_position.nil? || first_clause_position.positive?
21
25
 
@@ -31,9 +35,10 @@ module SqlBeautifier
31
35
  append_clause!(:order_by, Clauses::OrderBy)
32
36
  append_clause!(:limit, Clauses::Limit)
33
37
 
34
- output = @parts.join("\n\n")
38
+ output = @parts.join(clause_separator)
35
39
  return "#{@normalized_value}\n" if output.empty?
36
40
 
41
+ output = SubqueryFormatter.format(output, @depth)
37
42
  output = @table_registry.apply_aliases(output) if @table_registry
38
43
  "#{output}\n"
39
44
  end
@@ -53,5 +58,48 @@ module SqlBeautifier
53
58
 
54
59
  @parts << Clauses::From.call(value, table_registry: @table_registry)
55
60
  end
61
+
62
+ def clause_separator
63
+ return "\n\n" if SqlBeautifier.config_for(:clause_spacing_mode) == :spacious
64
+ return "\n\n" unless compact_query?
65
+
66
+ "\n"
67
+ end
68
+
69
+ def compact_query?
70
+ compact_clause_set? && single_select_column? && single_from_table? && one_or_fewer_conditions?
71
+ end
72
+
73
+ def compact_clause_set?
74
+ clause_keys = @clauses.keys
75
+ allowed_keys = %i[select from where order_by limit]
76
+
77
+ clause_keys.all? { |key| allowed_keys.include?(key) } && clause_keys.include?(:select) && clause_keys.include?(:from)
78
+ end
79
+
80
+ def single_select_column?
81
+ select_value = @clauses[:select]
82
+ return false unless select_value.present?
83
+
84
+ formatted_select = Clauses::Select.call(select_value)
85
+ formatted_select.lines.length == 1
86
+ end
87
+
88
+ def single_from_table?
89
+ from_value = @clauses[:from]
90
+ return false unless from_value.present?
91
+
92
+ join_keywords = Constants::JOIN_KEYWORDS_BY_LENGTH.any? { |keyword| Tokenizer.find_top_level_keyword(from_value, keyword) }
93
+ return false if join_keywords
94
+
95
+ !from_value.match?(%r{,})
96
+ end
97
+
98
+ def one_or_fewer_conditions?
99
+ where_value = @clauses[:where]
100
+ return true unless where_value.present?
101
+
102
+ Tokenizer.split_top_level_conditions(where_value).length <= 1
103
+ end
56
104
  end
57
105
  end
@@ -18,6 +18,11 @@ module SqlBeautifier
18
18
  @source = @value.strip
19
19
  return unless @source.present?
20
20
 
21
+ @source = strip_comments(@source)
22
+ @source = strip_trailing_semicolons(@source)
23
+ @source = @source.strip
24
+ return unless @source.present?
25
+
21
26
  @output = +""
22
27
  @position = 0
23
28
 
@@ -110,5 +115,71 @@ module SqlBeautifier
110
115
  def requires_quoting?(identifier)
111
116
  identifier !~ SAFE_UNQUOTED_IDENTIFIER
112
117
  end
118
+
119
+ def strip_trailing_semicolons(sql)
120
+ sql.sub(%r{;[[:space:]]*\z}, "")
121
+ end
122
+
123
+ def strip_comments(sql)
124
+ output = +""
125
+ position = 0
126
+ in_single_quoted_string = false
127
+ in_double_quoted_identifier = false
128
+
129
+ while position < sql.length
130
+ character = sql[position]
131
+
132
+ if in_single_quoted_string
133
+ output << character
134
+
135
+ if character == Constants::SINGLE_QUOTE && sql[position + 1] == Constants::SINGLE_QUOTE
136
+ position += 1
137
+ output << sql[position]
138
+ elsif character == Constants::SINGLE_QUOTE
139
+ in_single_quoted_string = false
140
+ end
141
+
142
+ position += 1
143
+ next
144
+ end
145
+
146
+ if in_double_quoted_identifier
147
+ output << character
148
+
149
+ if character == Constants::DOUBLE_QUOTE && sql[position + 1] == Constants::DOUBLE_QUOTE
150
+ position += 1
151
+ output << sql[position]
152
+ elsif character == Constants::DOUBLE_QUOTE
153
+ in_double_quoted_identifier = false
154
+ end
155
+
156
+ position += 1
157
+ next
158
+ end
159
+
160
+ if character == Constants::SINGLE_QUOTE
161
+ in_single_quoted_string = true
162
+ output << character
163
+ position += 1
164
+ elsif character == Constants::DOUBLE_QUOTE
165
+ in_double_quoted_identifier = true
166
+ output << character
167
+ position += 1
168
+ elsif character == "-" && sql[position + 1] == "-"
169
+ position += 2
170
+ position += 1 while position < sql.length && sql[position] != "\n"
171
+ elsif character == "/" && sql[position + 1] == "*"
172
+ output << " " unless output.empty? || output[-1] =~ Constants::WHITESPACE_CHARACTER_REGEX
173
+ position += 2
174
+ position += 1 while position < sql.length && !(sql[position] == "*" && sql[position + 1] == "/")
175
+ position += 2 if position < sql.length
176
+ else
177
+ output << character
178
+ position += 1
179
+ end
180
+ end
181
+
182
+ output
183
+ end
113
184
  end
114
185
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ module SubqueryFormatter
5
+ module_function
6
+
7
+ def format(text, base_indent)
8
+ output = +""
9
+ position = 0
10
+
11
+ while position < text.length
12
+ subquery_position = find_top_level_subquery(text, position)
13
+
14
+ unless subquery_position
15
+ output << text[position..]
16
+ break
17
+ end
18
+
19
+ output << text[position...subquery_position]
20
+
21
+ closing_position = Tokenizer.find_matching_parenthesis(text, subquery_position)
22
+
23
+ unless closing_position
24
+ output << text[subquery_position..]
25
+ break
26
+ end
27
+
28
+ inner_sql = text[(subquery_position + 1)...closing_position].strip
29
+ subquery_base_indent = subquery_base_indent_for(text, subquery_position, base_indent)
30
+ output << format_subquery(inner_sql, subquery_base_indent)
31
+ position = closing_position + 1
32
+ end
33
+
34
+ output
35
+ end
36
+
37
+ def find_top_level_subquery(text, start_position)
38
+ position = start_position
39
+ in_single_quoted_string = false
40
+ in_double_quoted_identifier = false
41
+ while position < text.length
42
+ character = text[position]
43
+
44
+ if in_single_quoted_string
45
+ if character == Constants::SINGLE_QUOTE && text[position + 1] == Constants::SINGLE_QUOTE
46
+ position += 2
47
+ elsif character == Constants::SINGLE_QUOTE
48
+ in_single_quoted_string = false
49
+ position += 1
50
+ else
51
+ position += 1
52
+ end
53
+ next
54
+ end
55
+
56
+ if in_double_quoted_identifier
57
+ if character == Constants::DOUBLE_QUOTE && text[position + 1] == Constants::DOUBLE_QUOTE
58
+ position += 2
59
+ elsif character == Constants::DOUBLE_QUOTE
60
+ in_double_quoted_identifier = false
61
+ position += 1
62
+ else
63
+ position += 1
64
+ end
65
+ next
66
+ end
67
+
68
+ case character
69
+ when Constants::SINGLE_QUOTE
70
+ in_single_quoted_string = true
71
+ when Constants::DOUBLE_QUOTE
72
+ in_double_quoted_identifier = true
73
+ when Constants::OPEN_PARENTHESIS
74
+ return position if select_follows?(text, position)
75
+ end
76
+
77
+ position += 1
78
+ end
79
+
80
+ nil
81
+ end
82
+
83
+ def format_subquery(inner_sql, base_indent)
84
+ indent_spaces = SqlBeautifier.config_for(:indent_spaces) || 4
85
+ subquery_indent = base_indent + indent_spaces
86
+ formatted = Formatter.new(inner_sql, depth: subquery_indent).call
87
+ return "(#{inner_sql})" unless formatted
88
+
89
+ indentation = Util.whitespace(subquery_indent)
90
+ indented_lines = formatted.chomp.lines.map { |line| line.strip.empty? ? "\n" : "#{indentation}#{line}" }.join
91
+
92
+ "(\n#{indented_lines}\n#{Util.whitespace(base_indent)})"
93
+ end
94
+
95
+ def subquery_base_indent_for(text, subquery_position, default_base_indent)
96
+ line_start_position = text.rindex("\n", subquery_position - 1)
97
+ line_start_position = line_start_position ? line_start_position + 1 : 0
98
+ line_before_subquery = text[line_start_position...subquery_position]
99
+ line_leading_spaces = line_before_subquery[%r{\A[[:space:]]*}].to_s.length
100
+
101
+ return default_base_indent unless line_before_subquery.lstrip.match?(%r{\Awhere(?:[[:space:]]|$)}i)
102
+
103
+ default_base_indent + line_leading_spaces + SqlBeautifier.config_for(:keyword_column_width)
104
+ end
105
+
106
+ def select_follows?(text, position)
107
+ remaining_text = text[(position + 1)..]
108
+ return false unless remaining_text
109
+
110
+ remaining_text.match?(%r{\A[[:space:]]*select(?:[[:space:]]|\()}i)
111
+ end
112
+ end
113
+ end
@@ -7,6 +7,7 @@ module SqlBeautifier
7
7
  def initialize(from_content)
8
8
  @from_content = from_content
9
9
  @table_map = {}
10
+ @alias_strategy = SqlBeautifier.config_for(:alias_strategy)
10
11
  build!
11
12
  end
12
13
 
@@ -15,6 +16,8 @@ module SqlBeautifier
15
16
  end
16
17
 
17
18
  def apply_aliases(text)
19
+ return text if @table_map.empty?
20
+
18
21
  output = +""
19
22
  position = 0
20
23
 
@@ -48,10 +51,19 @@ module SqlBeautifier
48
51
 
49
52
  def build!
50
53
  table_entries = extract_table_entries(@from_content)
51
- initials_occurrence_counts = count_initials_occurrences(table_entries)
52
- used_aliases = []
53
54
 
54
- assign_aliases!(table_entries, initials_occurrence_counts, used_aliases)
55
+ if @alias_strategy == :none
56
+ table_entries.each do |table_entry|
57
+ next unless table_entry[:explicit_alias]
58
+
59
+ @table_map[table_entry[:table_name]] = table_entry[:explicit_alias]
60
+ end
61
+ else
62
+ initials_occurrence_counts = count_initials_occurrences(table_entries)
63
+ used_aliases = []
64
+ assign_aliases!(table_entries, initials_occurrence_counts, used_aliases)
65
+ end
66
+
55
67
  @tables_by_descending_length = @table_map.keys.sort_by { |name| -name.length }.freeze
56
68
  end
57
69
 
@@ -163,6 +175,8 @@ module SqlBeautifier
163
175
  end
164
176
 
165
177
  def table_initials(table_name)
178
+ return @alias_strategy.call(table_name) if @alias_strategy.respond_to?(:call)
179
+
166
180
  table_name.split("_").map { |segment| segment[0] }.join
167
181
  end
168
182
 
@@ -4,6 +4,10 @@ module SqlBeautifier
4
4
  module Util
5
5
  module_function
6
6
 
7
+ def whitespace(length)
8
+ " " * length
9
+ end
10
+
7
11
  def upper_pascal_case(name)
8
12
  name.split("_").map(&:capitalize).join("_")
9
13
  end
@@ -30,5 +34,34 @@ module SqlBeautifier
30
34
 
31
35
  value.gsub(Constants::DOUBLE_QUOTE, Constants::ESCAPED_DOUBLE_QUOTE)
32
36
  end
37
+
38
+ def keyword_padding(keyword)
39
+ formatted_keyword = format_keyword(keyword)
40
+ padding_width = [SqlBeautifier.config_for(:keyword_column_width) - formatted_keyword.length, 1].max
41
+
42
+ "#{formatted_keyword}#{whitespace(padding_width)}"
43
+ end
44
+
45
+ def continuation_padding
46
+ whitespace(SqlBeautifier.config_for(:keyword_column_width))
47
+ end
48
+
49
+ def format_keyword(keyword)
50
+ case SqlBeautifier.config_for(:keyword_case)
51
+ when :upper
52
+ keyword.upcase
53
+ else
54
+ keyword.downcase
55
+ end
56
+ end
57
+
58
+ def format_table_name(name)
59
+ case SqlBeautifier.config_for(:table_name_format)
60
+ when :lowercase
61
+ name.downcase
62
+ else
63
+ upper_pascal_case(name)
64
+ end
65
+ end
33
66
  end
34
67
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqlBeautifier
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -5,11 +5,14 @@ require "active_support/core_ext/object/blank"
5
5
  require_relative "sql_beautifier/version"
6
6
  require_relative "sql_beautifier/constants"
7
7
  require_relative "sql_beautifier/util"
8
+ require_relative "sql_beautifier/configuration"
8
9
 
9
10
  require_relative "sql_beautifier/normalizer"
10
11
  require_relative "sql_beautifier/tokenizer"
11
12
  require_relative "sql_beautifier/table_registry"
12
13
  require_relative "sql_beautifier/condition_formatter"
14
+ require_relative "sql_beautifier/subquery_formatter"
15
+ require_relative "sql_beautifier/cte_formatter"
13
16
  require_relative "sql_beautifier/clauses/base"
14
17
  require_relative "sql_beautifier/clauses/condition_clause"
15
18
  require_relative "sql_beautifier/clauses/select"
@@ -31,4 +34,20 @@ module SqlBeautifier
31
34
 
32
35
  Formatter.call(value)
33
36
  end
37
+
38
+ def configuration
39
+ @configuration ||= Configuration.new
40
+ end
41
+
42
+ def configure
43
+ yield configuration
44
+ end
45
+
46
+ def config_for(key)
47
+ configuration.public_send(key)
48
+ end
49
+
50
+ def reset_configuration!
51
+ @configuration = Configuration.new
52
+ end
34
53
  end
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kinnell Shah
@@ -47,9 +47,12 @@ files:
47
47
  - lib/sql_beautifier/clauses/select.rb
48
48
  - lib/sql_beautifier/clauses/where.rb
49
49
  - lib/sql_beautifier/condition_formatter.rb
50
+ - lib/sql_beautifier/configuration.rb
50
51
  - lib/sql_beautifier/constants.rb
52
+ - lib/sql_beautifier/cte_formatter.rb
51
53
  - lib/sql_beautifier/formatter.rb
52
54
  - lib/sql_beautifier/normalizer.rb
55
+ - lib/sql_beautifier/subquery_formatter.rb
53
56
  - lib/sql_beautifier/table_registry.rb
54
57
  - lib/sql_beautifier/tokenizer.rb
55
58
  - lib/sql_beautifier/util.rb