sql_beautifier 0.6.0 → 0.7.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +2 -2
  4. data/lib/sql_beautifier/base.rb +9 -0
  5. data/lib/sql_beautifier/clauses/base.rb +2 -2
  6. data/lib/sql_beautifier/clauses/condition_clause.rb +1 -1
  7. data/lib/sql_beautifier/clauses/from.rb +15 -69
  8. data/lib/sql_beautifier/clauses/order_by.rb +12 -1
  9. data/lib/sql_beautifier/clauses/select.rb +28 -15
  10. data/lib/sql_beautifier/comment.rb +23 -0
  11. data/lib/sql_beautifier/{comment_stripper.rb → comment_parser.rb} +67 -24
  12. data/lib/sql_beautifier/condition.rb +162 -0
  13. data/lib/sql_beautifier/configuration.rb +4 -15
  14. data/lib/sql_beautifier/create_table_as.rb +127 -0
  15. data/lib/sql_beautifier/cte_definition.rb +41 -0
  16. data/lib/sql_beautifier/cte_query.rb +129 -0
  17. data/lib/sql_beautifier/expression.rb +54 -0
  18. data/lib/sql_beautifier/formatter.rb +13 -80
  19. data/lib/sql_beautifier/join.rb +69 -0
  20. data/lib/sql_beautifier/normalizer.rb +33 -59
  21. data/lib/sql_beautifier/query.rb +185 -0
  22. data/lib/sql_beautifier/scanner.rb +420 -0
  23. data/lib/sql_beautifier/sort_expression.rb +39 -0
  24. data/lib/sql_beautifier/statement_assembler.rb +4 -4
  25. data/lib/sql_beautifier/statement_splitter.rb +35 -143
  26. data/lib/sql_beautifier/table_reference.rb +52 -0
  27. data/lib/sql_beautifier/table_registry.rb +50 -124
  28. data/lib/sql_beautifier/tokenizer.rb +47 -278
  29. data/lib/sql_beautifier/types.rb +9 -0
  30. data/lib/sql_beautifier/version.rb +1 -1
  31. data/lib/sql_beautifier.rb +14 -6
  32. metadata +43 -7
  33. data/lib/sql_beautifier/comment_restorer.rb +0 -62
  34. data/lib/sql_beautifier/condition_formatter.rb +0 -127
  35. data/lib/sql_beautifier/create_table_as_formatter.rb +0 -177
  36. data/lib/sql_beautifier/cte_formatter.rb +0 -192
  37. data/lib/sql_beautifier/subquery_formatter.rb +0 -113
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae905b6c37cef236a53ae510743055f7c5fb5c184a1513152920aeaa23b6f79b
4
- data.tar.gz: ed5e4c3e0207d1cdf9f600852661fca974bd4f435d1e6c32fd3d471ab46e6288
3
+ metadata.gz: 189e4fb1ebb3e61fa40afa896d3ac83897714fb3236f3ef27a1a97da03c82fea
4
+ data.tar.gz: 9143a35ed8be4d33372d1d82bb05ec52d8acf3eb2ce34ea5880e0b7e5e53b2f9
5
5
  SHA512:
6
- metadata.gz: ef28e0c0853fb740d677be87061c284aeae9c8e5f0ed62f4d34417577d58c5ac87bcc0072c88a6f8bcfdac52a4cae3ff335cf8f1de07a9d1c7b3ba67533ed820
7
- data.tar.gz: 3f8f35358960a0c0015026251799621ee0a775aaa066a4dc53de36c4a973920f0f76052cfced812a861e4b372e88ca22430c432f77c1537642d6303c0468501a
6
+ metadata.gz: 991c1470b7beb1db6c405b709d5ee96f9d7562c27882e5a4f8269a2f3680d50a3787c326df87bd04c7331cab0b87f7c5b87867f03a094f5628f39cfaf0376bd9
7
+ data.tar.gz: a8cc4f9eed82b92905be0961adea425731ad5741e941b5eb6aa669341dbc1bb97ba6540721f9734497009823075769257fccae7ae4416b84ba76fa172b5d6694
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.7.0] - 2026-03-29
6
+
7
+ - Introduce `Query` entity encapsulating parsed clauses, depth, table registry, compact detection, and subquery formatting — `Formatter` delegates clause assembly and rendering to `Query`, and `SubqueryFormatter` is eliminated
8
+ - Introduce `CteQuery` and `CteDefinition` entities replacing `CteFormatter` — CTE parsing produces structured objects that render themselves
9
+ - Introduce `CreateTableAs` entity replacing `CreateTableAsFormatter` — structured object with modifier, if-not-exists, table name, body query, and suffix
10
+ - Introduce `Condition` tree model replacing flat `[conjunction, text]` pairs — parsed into leaf and group nodes with recursive rendering; eliminates `ConditionFormatter`
11
+ - Extract `Scanner` class consolidating duplicated character-by-character scanning logic across seven modules
12
+ - Consolidate `CommentStripper` and `CommentRestorer` into `CommentParser`
13
+ - Introduce `TableReference` and `Join` entities — `Clauses::From` delegates join rendering to `Join#render`; `TableRegistry` holds `TableReference` objects instead of raw hashes
14
+ - Introduce `Expression` entity for SELECT list items and `SortExpression` entity for ORDER BY items
15
+ - Introduce `Comment` entity with `content`, `type`, and `renderable` attributes
16
+
5
17
  ## [0.6.0] - 2026-03-28
6
18
 
7
19
  - **Breaking**: comments are now preserved by default. Set `removable_comment_types = :all` to restore previous behavior of stripping all comments
data/README.md CHANGED
@@ -445,12 +445,12 @@ end
445
445
  Controls which SQL comment types are stripped during formatting. Default: `:none`.
446
446
 
447
447
  - `:none` — preserves all comments in the formatted output
448
- - `:all` — strips all comments (equivalent to `[:inline, :separate_line, :blocks]`)
448
+ - `:all` — strips all comments (equivalent to `[:inline, :line, :blocks]`)
449
449
  - Array of specific types — strips only the listed types, preserving the rest
450
450
 
451
451
  The three comment types:
452
452
 
453
- - `:separate_line` — `--` comments on their own line (only whitespace before `--`), including banner-style dividers
453
+ - `:line` — `--` comments on their own line (only whitespace before `--`), including banner-style dividers
454
454
  - `:inline` — `--` comments at the end of a line that contains SQL
455
455
  - `:blocks` — `/* ... */` block comments (single or multi-line)
456
456
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module SqlBeautifier
6
+ class Base
7
+ extend Dry::Initializer
8
+ end
9
+ end
@@ -3,8 +3,8 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Base
6
- def self.call(value)
7
- new(value).call
6
+ def self.call(...)
7
+ new(...).call
8
8
  end
9
9
 
10
10
  def initialize(value)
@@ -6,7 +6,7 @@ module SqlBeautifier
6
6
  def call
7
7
  return "#{keyword_prefix}#{@value.strip}" unless multiple_conditions?
8
8
 
9
- formatted_conditions = ConditionFormatter.format(@value, indent_width: SqlBeautifier.config_for(:keyword_column_width))
9
+ formatted_conditions = Condition.format(@value, indent_width: SqlBeautifier.config_for(:keyword_column_width))
10
10
  formatted_conditions.sub(continuation_indent, keyword_prefix)
11
11
  end
12
12
 
@@ -5,66 +5,41 @@ module SqlBeautifier
5
5
  class From < Base
6
6
  KEYWORD = "from"
7
7
 
8
- def self.call(value, table_registry:)
9
- new(value, table_registry: table_registry).call
8
+ def self.call(...)
9
+ new(...).call
10
10
  end
11
11
 
12
12
  def initialize(value, table_registry:)
13
13
  super(value)
14
+
14
15
  @table_registry = table_registry
15
16
  end
16
17
 
17
18
  def call
18
- @lines = []
19
-
20
19
  join_parts = split_join_parts
21
20
  primary_table_text = join_parts.shift.strip
22
- formatted_primary_table_name = format_table_with_alias(primary_table_text)
23
- add_line!("#{keyword_prefix}#{formatted_primary_table_name}")
21
+ primary_reference = @table_registry.reference_for(Util.first_word(primary_table_text))
22
+ trailing_sentinels = Join.extract_trailing_sentinels(primary_table_text)
24
23
 
25
- join_parts.each { |join_part| format_join_part(join_part) }
24
+ lines = []
25
+ lines << "#{keyword_prefix}#{primary_reference.render(trailing_sentinels: trailing_sentinels)}"
26
26
 
27
- @lines.join("\n")
28
- end
27
+ join_parts.each do |join_text|
28
+ join = Join.parse(join_text, table_registry: @table_registry)
29
+ next unless join
29
30
 
30
- private
31
+ lines << join.render(continuation_indent: continuation_indent, condition_indent: join_condition_indentation)
32
+ end
31
33
 
32
- def add_line!(line)
33
- @lines << line
34
+ lines.join("\n")
34
35
  end
35
36
 
37
+ private
38
+
36
39
  def join_condition_indentation
37
40
  Util.whitespace(SqlBeautifier.config_for(:keyword_column_width) + 4)
38
41
  end
39
42
 
40
- def format_join_part(join_part)
41
- join_keyword, remaining_join_content = extract_join_keyword(join_part)
42
- return unless join_keyword && remaining_join_content
43
-
44
- on_keyword_position = Tokenizer.find_top_level_keyword(remaining_join_content, "on")
45
-
46
- if on_keyword_position
47
- format_join_with_conditions(join_keyword, remaining_join_content, on_keyword_position)
48
- else
49
- formatted_table_name = format_table_with_alias(remaining_join_content)
50
- add_line!("#{continuation_indent}#{join_keyword} #{formatted_table_name}")
51
- end
52
- end
53
-
54
- def format_join_with_conditions(join_keyword, join_content, on_keyword_position)
55
- table_text = join_content[0...on_keyword_position].strip
56
- condition_text = join_content[on_keyword_position..].delete_prefix("on").strip
57
- on_conditions = Tokenizer.split_top_level_conditions(condition_text)
58
-
59
- formatted_table_name = format_table_with_alias(table_text)
60
- first_condition = on_conditions.first[1]
61
- add_line!("#{continuation_indent}#{join_keyword} #{formatted_table_name} on #{first_condition}")
62
-
63
- on_conditions.drop(1).each do |conjunction, additional_condition|
64
- add_line!("#{join_condition_indentation}#{conjunction} #{additional_condition}")
65
- end
66
- end
67
-
68
43
  def split_join_parts
69
44
  from_content = @value.strip
70
45
  join_keyword_positions = find_all_join_keyword_positions(from_content)
@@ -122,35 +97,6 @@ module SqlBeautifier
122
97
 
123
98
  earliest_match
124
99
  end
125
-
126
- def extract_join_keyword(join_part)
127
- trimmed_join_text = join_part.strip
128
-
129
- Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
130
- next unless trimmed_join_text.downcase.start_with?(keyword)
131
-
132
- remaining_join_content = trimmed_join_text[keyword.length..].strip
133
-
134
- return [keyword, remaining_join_content]
135
- end
136
-
137
- [nil, nil]
138
- end
139
-
140
- def format_table_with_alias(table_text)
141
- table_name = Util.first_word(table_text)
142
- formatted_table_name = Util.format_table_name(table_name)
143
- table_alias = @table_registry.alias_for(table_name)
144
- trailing_sentinels = extract_trailing_sentinels(table_text)
145
-
146
- formatted = table_alias ? "#{formatted_table_name} #{table_alias}" : formatted_table_name
147
- trailing_sentinels.empty? ? formatted : "#{formatted} #{trailing_sentinels}"
148
- end
149
-
150
- def extract_trailing_sentinels(text)
151
- sentinels = text.scan(CommentStripper::SENTINEL_PATTERN).map { |match| "#{CommentStripper::SENTINEL_PREFIX}#{match[0]}#{CommentStripper::SENTINEL_SUFFIX}" }
152
- sentinels.join(" ")
153
- end
154
100
  end
155
101
  end
156
102
  end
@@ -6,7 +6,18 @@ module SqlBeautifier
6
6
  KEYWORD = "order by"
7
7
 
8
8
  def call
9
- "#{keyword_prefix}#{@value.strip}"
9
+ expressions = parse_expressions(@value)
10
+ expressions_output = expressions.map(&:render).join(", ")
11
+
12
+ "#{keyword_prefix}#{expressions_output}"
13
+ end
14
+
15
+ private
16
+
17
+ def parse_expressions(value)
18
+ Tokenizer.split_by_top_level_commas(value).map do |item|
19
+ SortExpression.parse(item)
20
+ end
10
21
  end
11
22
  end
12
23
  end
@@ -10,34 +10,45 @@ module SqlBeautifier
10
10
 
11
11
  def call
12
12
  prefix, remaining_columns = extract_prefix
13
- columns = Tokenizer.split_by_top_level_commas(remaining_columns)
13
+ @expressions = parse_expressions(remaining_columns)
14
14
 
15
- return format_with_prefix(prefix, columns) if prefix
16
- return keyword_line(columns.first) if columns.length == 1
15
+ return format_with_prefix(prefix) if prefix
16
+ return keyword_line(@expressions.first.render) if @expressions.length == 1
17
17
 
18
- format_columns_list(columns)
18
+ format_columns_list
19
19
  end
20
20
 
21
21
  private
22
22
 
23
- def keyword_line(column)
24
- "#{keyword_prefix}#{column.strip}"
23
+ def parse_expressions(value)
24
+ Tokenizer.split_by_top_level_commas(value).map do |column|
25
+ Expression.parse(column)
26
+ end
27
+ end
28
+
29
+ def keyword_line(text)
30
+ "#{keyword_prefix}#{text.strip}"
25
31
  end
26
32
 
27
- def continuation_line(column)
28
- "#{continuation_indent}#{column.strip}"
33
+ def continuation_line(text)
34
+ "#{continuation_indent}#{text.strip}"
29
35
  end
30
36
 
31
- def format_with_prefix(prefix, columns)
37
+ def format_with_prefix(prefix)
32
38
  first_line = "#{keyword_prefix}#{prefix}"
33
- column_lines = columns.map { |column| continuation_line(column) }
39
+ column_lines = @expressions.map do |expression|
40
+ continuation_line(expression.render)
41
+ end
34
42
 
35
43
  "#{first_line}\n#{column_lines.join(",\n")}"
36
44
  end
37
45
 
38
- def format_columns_list(columns)
39
- column_lines = columns.map { |column| continuation_line(column) }
40
- column_lines[0] = keyword_line(columns.first)
46
+ def format_columns_list
47
+ column_lines = @expressions.map do |expression|
48
+ continuation_line(expression.render)
49
+ end
50
+
51
+ column_lines[0] = keyword_line(@expressions.first.render)
41
52
 
42
53
  column_lines.join(",\n")
43
54
  end
@@ -61,11 +72,13 @@ module SqlBeautifier
61
72
  opening_parenthesis_position = stripped_value.index(Constants::OPEN_PARENTHESIS, distinct_on_position)
62
73
  return [nil, stripped_value] unless opening_parenthesis_position
63
74
 
64
- closing_parenthesis_position = Tokenizer.find_matching_parenthesis(stripped_value, opening_parenthesis_position)
75
+ closing_parenthesis_position = Scanner.new(stripped_value).find_matching_parenthesis(opening_parenthesis_position)
65
76
  return [nil, stripped_value] unless closing_parenthesis_position
66
77
 
67
78
  prefix = stripped_value[0..closing_parenthesis_position]
68
- remaining_columns = stripped_value[(closing_parenthesis_position + 1)..].strip.sub(LEADING_COMMA_PATTERN, "")
79
+ columns_text = stripped_value[(closing_parenthesis_position + 1)..]
80
+ stripped_columns_text = columns_text.strip
81
+ remaining_columns = stripped_columns_text.sub(LEADING_COMMA_PATTERN, "")
69
82
 
70
83
  [prefix, remaining_columns]
71
84
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ class Comment < Base
5
+ TYPES = %i[
6
+ inline
7
+ line
8
+ blocks
9
+ ].freeze
10
+
11
+ param :content
12
+ option :type, type: Types::Coercible::Symbol.enum(*TYPES), default: -> { :line }
13
+ option :renderable, type: Types::Bool, optional: true, default: -> { true }
14
+
15
+ def renderable?
16
+ @renderable
17
+ end
18
+
19
+ def render
20
+ @content
21
+ end
22
+ end
23
+ end
@@ -1,25 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqlBeautifier
4
- class CommentStripper
4
+ class CommentParser
5
5
  SENTINEL_PREFIX = "/*__sqlb_"
6
6
  SENTINEL_SUFFIX = "__*/"
7
7
  SENTINEL_PATTERN = %r{/\*__sqlb_(\d+)__\*/}
8
8
 
9
- Result = Struct.new(:stripped_sql, :comment_map)
9
+ Result = Struct.new(:stripped_sql, :comment_map, :comments)
10
10
 
11
11
  def self.call(sql, removable_types)
12
12
  new(sql, removable_types).call
13
13
  end
14
14
 
15
+ def self.restore(formatted_sql, comment_map)
16
+ return formatted_sql if comment_map.empty?
17
+
18
+ result = formatted_sql
19
+
20
+ comment_map.each do |index, entry|
21
+ sentinel = "#{SENTINEL_PREFIX}#{index}#{SENTINEL_SUFFIX}"
22
+
23
+ result = begin
24
+ case entry[:type]
25
+ when :blocks
26
+ result.sub(sentinel, entry[:text])
27
+ when :line
28
+ result.sub(%r{#{Regexp.escape(sentinel)}[ \n]?}, "#{entry[:text]}\n")
29
+ when :inline
30
+ restore_inline_comment(result, sentinel, entry[:text])
31
+ else
32
+ result
33
+ end
34
+ end
35
+ end
36
+
37
+ result
38
+ end
39
+
40
+ def self.restore_inline_comment(sql, sentinel, comment_text)
41
+ pattern = %r{ ?#{Regexp.escape(sentinel)}([^\n]*)}
42
+ sql.sub(pattern) do
43
+ trailing_content = Regexp.last_match(1)
44
+
45
+ if trailing_content.strip.empty?
46
+ " #{comment_text}"
47
+ else
48
+ "#{trailing_content.strip} #{comment_text}"
49
+ end
50
+ end
51
+ end
52
+
15
53
  def initialize(sql, removable_types)
16
54
  @sql = sql
17
55
  @removal_set = resolve_removal_set(removable_types)
18
56
  @output = +""
19
57
  @comment_map = {}
58
+ @comments = []
20
59
  @sentinel_index = 0
21
60
  @position = 0
22
- @pending_separate_line_comments = []
61
+ @pending_line_comments = []
23
62
  end
24
63
 
25
64
  def call
@@ -31,30 +70,30 @@ module SqlBeautifier
31
70
  elsif @in_double_quoted_identifier
32
71
  consume_double_quoted_character!(character)
33
72
  elsif character == Constants::SINGLE_QUOTE
34
- flush_pending_separate_line_comments!
73
+ flush_pending_line_comments!
35
74
  @in_single_quoted_string = true
36
75
  @output << character
37
76
  @position += 1
38
77
  elsif character == Constants::DOUBLE_QUOTE
39
- flush_pending_separate_line_comments!
78
+ flush_pending_line_comments!
40
79
  @in_double_quoted_identifier = true
41
80
  @output << character
42
81
  @position += 1
43
82
  elsif line_comment_start?
44
83
  handle_line_comment!
45
84
  elsif block_comment_start?
46
- flush_pending_separate_line_comments!
85
+ flush_pending_line_comments!
47
86
  handle_block_comment!
48
87
  else
49
- flush_pending_separate_line_comments! unless character == "\n"
88
+ flush_pending_line_comments! unless character == "\n"
50
89
  @output << character
51
90
  @position += 1
52
91
  end
53
92
  end
54
93
 
55
- flush_pending_separate_line_comments!
94
+ flush_pending_line_comments!
56
95
 
57
- Result.new(@output, @comment_map)
96
+ Result.new(@output, @comment_map, @comments)
58
97
  end
59
98
 
60
99
  private
@@ -64,16 +103,16 @@ module SqlBeautifier
64
103
  when :none
65
104
  []
66
105
  when :all
67
- Configuration::COMMENT_TYPES.dup
106
+ Comment::TYPES.dup
68
107
  when Array
69
- invalid_types = removable_types - Configuration::COMMENT_TYPES
70
- raise ArgumentError, "Unsupported removable_types entries: #{invalid_types.inspect}. Expected elements of #{Configuration::COMMENT_TYPES.inspect}" if invalid_types.any?
108
+ invalid_types = removable_types - Comment::TYPES
109
+ raise ArgumentError, "Unsupported removable_types entries: #{invalid_types.inspect}. Expected elements of #{Comment::TYPES.inspect}" if invalid_types.any?
71
110
 
72
111
  removable_types
73
- when *Configuration::COMMENT_TYPES
112
+ when *Comment::TYPES
74
113
  [removable_types]
75
114
  else
76
- raise ArgumentError, "Unsupported removable_types: #{removable_types.inspect}. Expected :none, :all, an Array, or one of #{Configuration::COMMENT_TYPES.inspect}"
115
+ raise ArgumentError, "Unsupported removable_types: #{removable_types.inspect}. Expected :none, :all, an Array, or one of #{Comment::TYPES.inspect}"
77
116
  end
78
117
  end
79
118
 
@@ -112,9 +151,11 @@ module SqlBeautifier
112
151
  end
113
152
 
114
153
  def handle_line_comment!
115
- comment_type = separate_line_comment? ? :separate_line : :inline
154
+ comment_type = line_comment? ? :line : :inline
116
155
  comment_text = extract_line_comment_text
117
156
 
157
+ @comments << Comment.new(comment_text, type: comment_type, renderable: !removable?(comment_type))
158
+
118
159
  if removable?(comment_type)
119
160
  strip_line_comment!
120
161
  else
@@ -122,7 +163,7 @@ module SqlBeautifier
122
163
  end
123
164
  end
124
165
 
125
- def separate_line_comment?
166
+ def line_comment?
126
167
  line_start = @output.rindex("\n")
127
168
  preceding_content = begin
128
169
  if line_start
@@ -147,11 +188,11 @@ module SqlBeautifier
147
188
  def strip_line_comment!; end
148
189
 
149
190
  def preserve_line_comment!(comment_type, comment_text)
150
- if comment_type == :separate_line
151
- @pending_separate_line_comments << comment_text
191
+ if comment_type == :line
192
+ @pending_line_comments << comment_text
152
193
  @position += 1 if @position < @sql.length && @sql[@position] == "\n"
153
194
  else
154
- flush_pending_separate_line_comments!
195
+ flush_pending_line_comments!
155
196
  sentinel = build_sentinel(comment_type, comment_text)
156
197
  @output << sentinel
157
198
  end
@@ -160,6 +201,8 @@ module SqlBeautifier
160
201
  def handle_block_comment!
161
202
  comment_text = extract_block_comment_text
162
203
 
204
+ @comments << Comment.new(comment_text, type: :blocks, renderable: !removable?(:blocks))
205
+
163
206
  if removable?(:blocks)
164
207
  strip_block_comment!
165
208
  else
@@ -192,16 +235,16 @@ module SqlBeautifier
192
235
  @output << " " unless @output.empty? || @output[-1] =~ Constants::WHITESPACE_CHARACTER_REGEX
193
236
  end
194
237
 
195
- def flush_pending_separate_line_comments!
196
- return if @pending_separate_line_comments.empty?
238
+ def flush_pending_line_comments!
239
+ return if @pending_line_comments.empty?
197
240
 
198
- grouped_text = @pending_separate_line_comments.join("\n")
199
- sentinel = build_sentinel(:separate_line, grouped_text)
241
+ grouped_text = @pending_line_comments.join("\n")
242
+ sentinel = build_sentinel(:line, grouped_text)
200
243
 
201
244
  @output << sentinel
202
245
  @output << "\n"
203
246
 
204
- @pending_separate_line_comments.clear
247
+ @pending_line_comments.clear
205
248
  end
206
249
 
207
250
  def build_sentinel(comment_type, comment_text)
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ class Condition < Base
5
+ option :conjunction, default: -> {}
6
+ option :expression, default: -> {}
7
+ option :children, default: -> {}
8
+
9
+ def self.format(text, indent_width: 0)
10
+ conditions = parse_all(text)
11
+ return text.strip if conditions.length <= 1 && conditions.first&.leaf?
12
+
13
+ render_all(conditions, indent_width: indent_width)
14
+ end
15
+
16
+ def self.parse_all(text)
17
+ raw_pairs = Tokenizer.split_top_level_conditions(text)
18
+ conditions = raw_pairs.map do |conjunction, condition_text|
19
+ build(conjunction, condition_text)
20
+ end
21
+
22
+ flatten_same_conjunction_groups(conditions)
23
+ end
24
+
25
+ def self.render_all(conditions, indent_width:)
26
+ indentation = Util.whitespace(indent_width)
27
+ lines = []
28
+
29
+ conditions.each_with_index do |condition, index|
30
+ rendered = condition.render(indent_width: indent_width)
31
+
32
+ line = begin
33
+ if index.zero?
34
+ "#{indentation}#{rendered}"
35
+ else
36
+ "#{indentation}#{condition.conjunction} #{rendered}"
37
+ end
38
+ end
39
+
40
+ lines << line
41
+ end
42
+
43
+ lines.join("\n")
44
+ end
45
+
46
+ def leaf?
47
+ @children.nil?
48
+ end
49
+
50
+ def group?
51
+ !leaf?
52
+ end
53
+
54
+ def render(indent_width:)
55
+ return @expression if leaf?
56
+
57
+ inline_version = render_inline
58
+ return inline_version if inline_version.length <= SqlBeautifier.config_for(:inline_group_threshold)
59
+
60
+ inner_output = self.class.render_all(@children, indent_width: indent_width + 4)
61
+ closing_indentation = Util.whitespace(indent_width)
62
+
63
+ "(\n#{inner_output}\n#{closing_indentation})"
64
+ end
65
+
66
+ def self.build(conjunction, condition_text)
67
+ unwrapped = unwrap_single_condition(condition_text)
68
+ inner_conditions = parse_condition_group(unwrapped)
69
+
70
+ if inner_conditions
71
+ children = inner_conditions.map do |inner_conjunction, inner_text|
72
+ build(inner_conjunction, inner_text)
73
+ end
74
+
75
+ new(conjunction: conjunction, children: children)
76
+ else
77
+ new(conjunction: conjunction, expression: unwrapped)
78
+ end
79
+ end
80
+
81
+ def self.unwrap_single_condition(condition_text)
82
+ output = condition_text.strip
83
+
84
+ while Tokenizer.outer_parentheses_wrap_all?(output)
85
+ inner_content = Util.strip_outer_parentheses(output)
86
+ inner_conditions = Tokenizer.split_top_level_conditions(inner_content)
87
+ break if inner_conditions.length > 1
88
+
89
+ output = inner_content
90
+ end
91
+
92
+ output
93
+ end
94
+
95
+ def self.parse_condition_group(condition_text)
96
+ return unless condition_text
97
+
98
+ trimmed = condition_text.strip
99
+ return unless Tokenizer.outer_parentheses_wrap_all?(trimmed)
100
+
101
+ inner_content = Util.strip_outer_parentheses(trimmed)
102
+ inner_conditions = Tokenizer.split_top_level_conditions(inner_content)
103
+ return unless inner_conditions.length > 1
104
+
105
+ inner_conditions
106
+ end
107
+
108
+ def self.flatten_same_conjunction_groups(conditions)
109
+ return conditions if conditions.length <= 1
110
+
111
+ outer_conjunction = conditions[1]&.conjunction
112
+ return conditions unless outer_conjunction
113
+ return conditions unless conditions.drop(1).all? { |condition| condition.conjunction == outer_conjunction }
114
+
115
+ flattened = []
116
+
117
+ conditions.each do |condition|
118
+ if condition.group? && flattenable_into_conjunction?(condition, outer_conjunction)
119
+ flatten_group_into!(flattened, condition, outer_conjunction)
120
+ else
121
+ flattened << condition
122
+ end
123
+ end
124
+
125
+ flattened
126
+ end
127
+
128
+ def self.flattenable_into_conjunction?(condition, outer_conjunction)
129
+ return false unless condition.group?
130
+
131
+ inner_conjunction = condition.children[1]&.conjunction
132
+ inner_conjunction == outer_conjunction && condition.children.drop(1).all? { |child| child.conjunction == outer_conjunction }
133
+ end
134
+
135
+ def self.flatten_group_into!(flattened, group_condition, outer_conjunction)
136
+ group_condition.children.each_with_index do |child, inner_index|
137
+ new_conjunction = begin
138
+ if flattened.empty?
139
+ nil
140
+ elsif inner_index.zero?
141
+ group_condition.conjunction || outer_conjunction
142
+ else
143
+ outer_conjunction
144
+ end
145
+ end
146
+
147
+ flattened << new(conjunction: new_conjunction, expression: child.expression, children: child.children)
148
+ end
149
+ end
150
+
151
+ protected
152
+
153
+ def render_inline
154
+ parts = @children.map.with_index do |child, index|
155
+ rendered = child.leaf? ? child.expression : child.render_inline
156
+ index.zero? ? rendered : "#{child.conjunction} #{rendered}"
157
+ end
158
+
159
+ "(#{parts.join(' ')})"
160
+ end
161
+ end
162
+ end