sql_beautifier 0.7.0 → 0.8.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: 189e4fb1ebb3e61fa40afa896d3ac83897714fb3236f3ef27a1a97da03c82fea
4
- data.tar.gz: 9143a35ed8be4d33372d1d82bb05ec52d8acf3eb2ce34ea5880e0b7e5e53b2f9
3
+ metadata.gz: 4a22a4c99d49622a7631196a0622d66ebcfb81144bbd85238e854cf5fb7ad6d3
4
+ data.tar.gz: 685310c29776884c4164feeb272d6fdac9fb4e640df7272170ba6b0530ef10b4
5
5
  SHA512:
6
- metadata.gz: 991c1470b7beb1db6c405b709d5ee96f9d7562c27882e5a4f8269a2f3680d50a3787c326df87bd04c7331cab0b87f7c5b87867f03a094f5628f39cfaf0376bd9
7
- data.tar.gz: a8cc4f9eed82b92905be0961adea425731ad5741e941b5eb6aa669341dbc1bb97ba6540721f9734497009823075769257fccae7ae4416b84ba76fa172b5d6694
6
+ metadata.gz: a4df9d20c0c4c92c6fe59ffbaa270ab82ed7356ffdb545d2055cae874d35c1f64347dd67971ac1260bfced3bf41cfd9dbf83bc77472ed5c1664b18772e4210c4
7
+ data.tar.gz: 26aebcef3cd11bc88dd898faee9d693bc72458e130ec814370c6163a903bbabb33c9cedd10cb40aadd5b7ea2f6ebc1edb42c0e8a21dc5471ca2a5c7ec0412c37
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.8.0] - 2026-03-29
6
+
7
+ - Add compound query support for set operators (`UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`) — top-level set operator boundaries are detected via `Scanner`, each segment is independently formatted through the `Formatter` pipeline, and operators appear on their own line with blank-line separation
8
+ - Introduce `CompoundQuery` entity class with `parse`/`render` following the `Base` + `dry-initializer` pattern established by `CteQuery` and `CreateTableAs`
9
+ - Add trailing clause handling for compound queries — `ORDER BY` and `LIMIT` after the final segment are extracted and rendered separately below the last formatted segment
10
+ - Fix `StatementSplitter` incorrectly splitting compound queries at the second `SELECT` — set operator keywords at depth 0 now suppress statement boundary detection for the following `SELECT`
11
+ - Add `SET_OPERATORS` constant to `Constants` (longest-first order for greedy matching)
12
+
5
13
  ## [0.7.0] - 2026-03-29
6
14
 
7
15
  - 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
data/README.md CHANGED
@@ -227,6 +227,61 @@ order by created_at desc
227
227
  limit 25;
228
228
  ```
229
229
 
230
+ ### Set Operators (UNION, INTERSECT, EXCEPT)
231
+
232
+ 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:
233
+
234
+ ```ruby
235
+ SqlBeautifier.call(<<~SQL)
236
+ SELECT id, name FROM users WHERE active = true
237
+ UNION ALL
238
+ SELECT id, name FROM admins WHERE role = 'super'
239
+ SQL
240
+ ```
241
+
242
+ Produces:
243
+
244
+ ```sql
245
+ select id,
246
+ name
247
+
248
+ from Users u
249
+
250
+ where active = true
251
+
252
+ union all
253
+
254
+ select id,
255
+ name
256
+
257
+ from Admins a
258
+
259
+ where role = 'super';
260
+ ```
261
+
262
+ Supported operators: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`. Multiple operators can be mixed in a single query. Trailing `ORDER BY` and `LIMIT` that apply to the compound result are rendered after the last segment:
263
+
264
+ ```ruby
265
+ SqlBeautifier.call("SELECT id FROM users UNION ALL SELECT id FROM admins ORDER BY id LIMIT 10")
266
+ ```
267
+
268
+ Produces:
269
+
270
+ ```sql
271
+ select id
272
+ from Users u
273
+
274
+ union all
275
+
276
+ select id
277
+ from Admins a
278
+
279
+ order by id
280
+ limit 10;
281
+ ```
282
+
283
+ Set operators inside parenthesized subqueries are handled correctly and do not split the outer query. Each segment is formatted with its own independent table registry, so alias collisions between segments are not a concern.
284
+
230
285
  ### String Literals
231
286
 
232
287
  Case is preserved inside single-quoted string literals, and escaped quotes (`''`) are handled correctly:
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ class CompoundQuery < Base
5
+ TRAILING_CLAUSE_KEYWORDS = ["order by", "limit"].freeze
6
+
7
+ option :segments
8
+ option :trailing_clauses, default: -> {}
9
+ option :depth, default: -> { 0 }
10
+
11
+ def self.parse(normalized_sql, depth: 0)
12
+ boundaries = scan_set_operator_boundaries(normalized_sql)
13
+ return nil if boundaries.empty?
14
+
15
+ segments = build_segments(normalized_sql, boundaries)
16
+ trailing_clauses = extract_trailing_clauses!(segments)
17
+
18
+ new(segments: segments, trailing_clauses: trailing_clauses, depth: depth)
19
+ end
20
+
21
+ def render
22
+ formatted_segments = []
23
+
24
+ @segments.each_with_index do |segment, index|
25
+ formatted_sql = Formatter.new(segment[:sql], depth: @depth).call
26
+ return nil unless formatted_sql
27
+
28
+ output = +""
29
+ output << "\n#{Util.format_keyword(segment[:operator])}\n\n" if index.positive? && segment[:operator]
30
+ output << formatted_sql.chomp
31
+ formatted_segments << output
32
+ end
33
+
34
+ return nil if formatted_segments.empty?
35
+
36
+ result = formatted_segments.join("\n")
37
+ result << render_trailing_clauses if @trailing_clauses.present?
38
+
39
+ "#{result}\n"
40
+ end
41
+
42
+ def self.scan_set_operator_boundaries(normalized_sql)
43
+ scanner = Scanner.new(normalized_sql)
44
+ boundaries = []
45
+
46
+ until scanner.finished?
47
+ consumed = scanner.scan_quoted_or_sentinel!
48
+ next if consumed
49
+
50
+ case scanner.current_char
51
+ when Constants::OPEN_PARENTHESIS
52
+ scanner.increment_depth!
53
+ when Constants::CLOSE_PARENTHESIS
54
+ scanner.decrement_depth!
55
+ else
56
+ if scanner.parenthesis_depth.zero?
57
+ matched_operator = detect_set_operator(scanner)
58
+
59
+ if matched_operator
60
+ boundaries << { operator: matched_operator, position: scanner.position }
61
+ scanner.advance!(matched_operator.length)
62
+ next
63
+ end
64
+ end
65
+ end
66
+
67
+ scanner.advance!
68
+ end
69
+
70
+ boundaries
71
+ end
72
+
73
+ def self.detect_set_operator(scanner)
74
+ Constants::SET_OPERATORS.detect { |operator| scanner.keyword_at?(operator) }
75
+ end
76
+
77
+ def self.build_segments(normalized_sql, boundaries)
78
+ segments = []
79
+
80
+ boundaries.each_with_index do |boundary, index|
81
+ previous_boundary = boundaries[index - 1] unless index.zero?
82
+ segment_start = previous_boundary ? previous_boundary[:position] + previous_boundary[:operator].length : 0
83
+ segment_sql = normalized_sql[segment_start...boundary[:position]].strip
84
+
85
+ segments << { operator: previous_boundary&.fetch(:operator), sql: segment_sql }
86
+ end
87
+
88
+ last_boundary = boundaries.last
89
+ final_segment_start = last_boundary[:position] + last_boundary[:operator].length
90
+ final_segment_sql = normalized_sql[final_segment_start..].strip
91
+
92
+ segments << { operator: last_boundary[:operator], sql: final_segment_sql }
93
+
94
+ segments
95
+ end
96
+
97
+ def self.extract_trailing_clauses!(segments)
98
+ final_segment = segments.last
99
+ return nil unless final_segment
100
+
101
+ sql = final_segment[:sql]
102
+ trailing_start = find_trailing_clause_start(sql)
103
+ return nil unless trailing_start
104
+
105
+ trailing_sql = sql[trailing_start..].strip
106
+ final_segment[:sql] = sql[0...trailing_start].strip
107
+
108
+ trailing_sql
109
+ end
110
+
111
+ def self.find_trailing_clause_start(sql)
112
+ scanner = Scanner.new(sql)
113
+ trailing_position = nil
114
+
115
+ until scanner.finished?
116
+ consumed = scanner.scan_quoted_or_sentinel!
117
+ next if consumed
118
+
119
+ case scanner.current_char
120
+ when Constants::OPEN_PARENTHESIS
121
+ scanner.increment_depth!
122
+ when Constants::CLOSE_PARENTHESIS
123
+ scanner.decrement_depth!
124
+ else
125
+ if scanner.parenthesis_depth.zero?
126
+ matched_trailing_keyword = TRAILING_CLAUSE_KEYWORDS.detect { |keyword| scanner.keyword_at?(keyword) }
127
+ trailing_position ||= scanner.position if matched_trailing_keyword
128
+ end
129
+ end
130
+
131
+ scanner.advance!
132
+ end
133
+
134
+ trailing_position
135
+ end
136
+
137
+ private_class_method :scan_set_operator_boundaries, :detect_set_operator, :build_segments, :extract_trailing_clauses!, :find_trailing_clause_start
138
+
139
+ private
140
+
141
+ def render_trailing_clauses
142
+ clauses = Tokenizer.split_into_clauses(@trailing_clauses)
143
+ return "" if clauses.empty?
144
+
145
+ clause_renderers = {
146
+ order_by: Clauses::OrderBy,
147
+ limit: Clauses::Limit,
148
+ }
149
+
150
+ rendered_clauses = clauses.filter_map do |clause_key, clause_value|
151
+ renderer = clause_renderers[clause_key]
152
+ next unless renderer
153
+
154
+ renderer.call(clause_value)
155
+ end
156
+
157
+ return "" if rendered_clauses.empty?
158
+
159
+ "\n\n#{rendered_clauses.join("\n")}"
160
+ end
161
+ end
162
+ end
@@ -26,6 +26,15 @@ module SqlBeautifier
26
26
  JOIN_KEYWORDS_BY_LENGTH = JOIN_KEYWORDS.sort_by { |keyword| -keyword.length }.freeze
27
27
  JOIN_KEYWORD_PATTERN = %r{\b(#{JOIN_KEYWORDS.map { |keyword| Regexp.escape(keyword) }.join('|')})\b}i
28
28
 
29
+ SET_OPERATORS = [
30
+ "intersect all",
31
+ "except all",
32
+ "union all",
33
+ "intersect",
34
+ "except",
35
+ "union",
36
+ ].freeze
37
+
29
38
  CONJUNCTIONS = %w[and or].freeze
30
39
  BETWEEN_KEYWORD = "between"
31
40
 
@@ -29,6 +29,9 @@ module SqlBeautifier
29
29
  create_table_as_result = CreateTableAs.parse(@normalized_value, depth: @depth)&.render
30
30
  return prepend_sentinels(create_table_as_result) if create_table_as_result
31
31
 
32
+ compound_result = CompoundQuery.parse(@normalized_value, depth: @depth)&.render
33
+ return prepend_sentinels(compound_result) if compound_result
34
+
32
35
  first_clause_position = Tokenizer.first_clause_position(@normalized_value)
33
36
  return prepend_sentinels("#{@normalized_value}\n") if first_clause_position.nil? || first_clause_position.positive?
34
37
 
@@ -85,6 +85,7 @@ module SqlBeautifier
85
85
  boundaries = []
86
86
  clause_seen = false
87
87
  current_statement_keyword = nil
88
+ after_set_operator = false
88
89
 
89
90
  until scanner.finished?
90
91
  next if scanner.skip_quoted_or_sentinel!
@@ -102,10 +103,20 @@ module SqlBeautifier
102
103
  scanner.advance!
103
104
  else
104
105
  if scanner.parenthesis_depth.zero?
106
+ matched_set_operator = keyword_match_at(scanner, Constants::SET_OPERATORS)
107
+
108
+ if matched_set_operator
109
+ after_set_operator = true
110
+ scanner.advance!(matched_set_operator.length)
111
+ next
112
+ end
113
+
105
114
  matched_statement_keyword = keyword_match_at(scanner, STATEMENT_KEYWORDS)
106
115
 
107
116
  if matched_statement_keyword
108
- if clause_seen && !continuation_keyword?(current_statement_keyword, matched_statement_keyword)
117
+ if after_set_operator
118
+ after_set_operator = false
119
+ elsif clause_seen && !continuation_keyword?(current_statement_keyword, matched_statement_keyword)
109
120
  boundaries << scanner.position
110
121
  clause_seen = false
111
122
  current_statement_keyword = matched_statement_keyword
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqlBeautifier
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -24,6 +24,7 @@ require_relative "sql_beautifier/condition"
24
24
  require_relative "sql_beautifier/cte_definition"
25
25
  require_relative "sql_beautifier/cte_query"
26
26
  require_relative "sql_beautifier/create_table_as"
27
+ require_relative "sql_beautifier/compound_query"
27
28
  require_relative "sql_beautifier/clauses/base"
28
29
  require_relative "sql_beautifier/clauses/condition_clause"
29
30
  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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kinnell Shah
@@ -77,6 +77,7 @@ files:
77
77
  - lib/sql_beautifier/clauses/where.rb
78
78
  - lib/sql_beautifier/comment.rb
79
79
  - lib/sql_beautifier/comment_parser.rb
80
+ - lib/sql_beautifier/compound_query.rb
80
81
  - lib/sql_beautifier/condition.rb
81
82
  - lib/sql_beautifier/configuration.rb
82
83
  - lib/sql_beautifier/constants.rb