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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +55 -0
- data/lib/sql_beautifier/compound_query.rb +162 -0
- data/lib/sql_beautifier/constants.rb +9 -0
- data/lib/sql_beautifier/formatter.rb +3 -0
- data/lib/sql_beautifier/statement_splitter.rb +12 -1
- data/lib/sql_beautifier/version.rb +1 -1
- data/lib/sql_beautifier.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a22a4c99d49622a7631196a0622d66ebcfb81144bbd85238e854cf5fb7ad6d3
|
|
4
|
+
data.tar.gz: 685310c29776884c4164feeb272d6fdac9fb4e640df7272170ba6b0530ef10b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
data/lib/sql_beautifier.rb
CHANGED
|
@@ -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.
|
|
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
|