sql_beautifier 0.9.1 → 0.9.2

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: 52e4fa6ef07975cadc1b7eaf77dbf0f3000552b1a91268edd22b64a1407f85b0
4
- data.tar.gz: bcda04e6aecb08a400bedee58ee41b8813030fb202f51e0d1d44365afae72990
3
+ metadata.gz: bcf549a9025b3f1baa8e3e6586154ce0e4b9529985f8e54b49ff241e4a24cd19
4
+ data.tar.gz: ce6eb31a10ad63436442bf0a27d4054857540a03d2b799ecf40db46246ac0852
5
5
  SHA512:
6
- metadata.gz: 48911b7894e8251f47c2256da78888ba1fd992f65341d3fa70cc4429d92a7ba7008d4564360eca5e860e91d61f59e0718207abdc61680852e3a8614c478264f3
7
- data.tar.gz: 5b3c2ab5629a09a6a4c6477c3aaa094f8d03bf1123a1c2cfd1f217ff15463b011d0faf8b8a24e864358ec62a4bf540acc1921400fd0ff7c25d74e2579e3d6e43
6
+ metadata.gz: f2bed68bdfff3e132819a44756f57da8b8e8445738d85e967ea98d108510afe4c2840f0f4a2ef592bf0cbbccac8facfaf247b31e2f8810c6e22d65c8ecee551d
7
+ data.tar.gz: 595b15be6f7f1a6b7541580d679ef0bde692bca70e4cffec251ea4f995aa3f54c17305b28b2a62ee0172ed40ca1137a013f4041d5ecf2624bcd22a7563a52672
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.9.2] - 2026-03-30
6
+
7
+ - Fix derived tables (subqueries in `FROM` clauses) losing their content during formatting — `TableRegistry#parse_references` used a regex split that did not respect parenthesis depth, causing JOIN keywords inside derived table subqueries to be treated as top-level boundaries
8
+ - Add derived table support to `TableReference` — segments starting with `(` are parsed as derived tables, preserving the full expression and extracting the alias from text after the closing `)`
9
+ - Extract `find_all_top_level_join_positions` and `find_earliest_top_level_join_keyword` into `Tokenizer` for shared use by `Clauses::From` and `TableRegistry`
10
+ - Improve subquery indentation for `FROM`-line subqueries — derived tables now align with keyword column width, matching the existing behavior for `WHERE`-line subqueries
11
+ - Fix aliasless derived tables in `FROM` clauses raising `NoMethodError` or receiving malformed auto-generated aliases — lookup now falls back to the full derived-table expression when no alias is present and alias assignment skips aliasless derived tables
12
+
5
13
  ## [0.9.1] - 2026-03-29
6
14
 
7
15
  ## [0.9.0] - 2026-03-29
data/README.md CHANGED
@@ -469,6 +469,23 @@ where id in (
469
469
 
470
470
  Nested subqueries increase indentation at each level.
471
471
 
472
+ Derived tables (subqueries in `FROM` clauses) are also supported — the subquery content is recursively formatted and the alias is preserved:
473
+
474
+ ```ruby
475
+ SqlBeautifier.call("SELECT active_users.id FROM (SELECT id FROM users WHERE active = true) AS active_users")
476
+ ```
477
+
478
+ Produces:
479
+
480
+ ```sql
481
+ select active_users.id
482
+ from (
483
+ select id
484
+ from Users u
485
+ where active = true
486
+ ) active_users;
487
+ ```
488
+
472
489
  ### Trailing Semicolons
473
490
 
474
491
  By default, each formatted statement ends with a `;`:
@@ -18,7 +18,8 @@ module SqlBeautifier
18
18
  def call
19
19
  join_parts = split_join_parts
20
20
  primary_table_text = join_parts.shift.strip
21
- primary_reference = @table_registry.reference_for(Util.first_word(primary_table_text))
21
+ primary_lookup_name = TableReference.derived_table_lookup_name_from(primary_table_text) || Util.first_word(primary_table_text)
22
+ primary_reference = @table_registry.reference_for(primary_lookup_name)
22
23
  trailing_sentinels = Join.extract_trailing_sentinels(primary_table_text)
23
24
 
24
25
  lines = []
@@ -42,7 +43,7 @@ module SqlBeautifier
42
43
 
43
44
  def split_join_parts
44
45
  from_content = @value.strip
45
- join_keyword_positions = find_all_join_keyword_positions(from_content)
46
+ join_keyword_positions = Tokenizer.find_all_top_level_join_positions(from_content)
46
47
 
47
48
  return [from_content] if join_keyword_positions.empty?
48
49
 
@@ -62,41 +63,6 @@ module SqlBeautifier
62
63
 
63
64
  parts
64
65
  end
65
-
66
- def find_all_join_keyword_positions(text)
67
- positions = []
68
- search_offset = 0
69
-
70
- while search_offset < text.length
71
- earliest_match = find_earliest_join_keyword(text, search_offset)
72
- break unless earliest_match
73
-
74
- positions << earliest_match
75
- search_offset = earliest_match[:position] + earliest_match[:keyword].length
76
- end
77
-
78
- positions
79
- end
80
-
81
- def find_earliest_join_keyword(text, search_offset)
82
- earliest_match = nil
83
-
84
- Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
85
- remaining_text = text[search_offset..]
86
- keyword_position = Tokenizer.find_top_level_keyword(remaining_text, keyword)
87
- next unless keyword_position
88
-
89
- absolute_position = search_offset + keyword_position
90
- next if earliest_match && absolute_position >= earliest_match[:position]
91
-
92
- earliest_match = {
93
- position: absolute_position,
94
- keyword: keyword,
95
- }
96
- end
97
-
98
- earliest_match
99
- end
100
66
  end
101
67
  end
102
68
  end
@@ -22,11 +22,13 @@ module SqlBeautifier
22
22
  conditions = []
23
23
  end
24
24
 
25
- table_name = Util.first_word(table_text)
26
- table_ref = table_registry.reference_for(table_name)
25
+ table_lookup_name = TableReference.derived_table_lookup_name_from(table_text) || Util.first_word(table_text)
26
+ table_reference = table_registry.reference_for(table_lookup_name)
27
+ return unless table_reference
28
+
27
29
  trailing_sentinels = extract_trailing_sentinels(table_text)
28
30
 
29
- new(keyword: keyword, table_reference: table_ref, trailing_sentinels: trailing_sentinels, conditions: conditions)
31
+ new(keyword: keyword, table_reference: table_reference, trailing_sentinels: trailing_sentinels, conditions: conditions)
30
32
  end
31
33
 
32
34
  def self.extract_keyword(join_text)
@@ -11,7 +11,7 @@ module SqlBeautifier
11
11
  ].freeze
12
12
 
13
13
  LEADING_WHITESPACE_PATTERN = %r{\A[[:space:]]*}
14
- WHERE_PREFIX_PATTERN = %r{\Awhere(?:[[:space:]]|$)}i
14
+ CLAUSE_KEYWORD_PREFIX_PATTERN = %r{\A(?:where|from)(?:[[:space:]]|$)}i
15
15
 
16
16
  attr_reader :clauses
17
17
  attr_reader :depth
@@ -88,7 +88,7 @@ module SqlBeautifier
88
88
  line_before_subquery = text[line_start_position...subquery_position]
89
89
  line_leading_spaces = line_before_subquery[LEADING_WHITESPACE_PATTERN].to_s.length
90
90
 
91
- return default_base_indent unless line_before_subquery.lstrip.match?(WHERE_PREFIX_PATTERN)
91
+ return default_base_indent unless line_before_subquery.lstrip.match?(CLAUSE_KEYWORD_PREFIX_PATTERN)
92
92
 
93
93
  default_base_indent + line_leading_spaces + SqlBeautifier.config_for(:keyword_column_width)
94
94
  end
@@ -5,13 +5,33 @@ module SqlBeautifier
5
5
  option :name
6
6
  option :explicit_alias, default: -> {}
7
7
  option :assigned_alias, default: -> {}
8
+ option :derived_table_expression, default: -> {}
8
9
 
9
10
  def self.parse(segment_text)
10
11
  table_specification = table_specification_text(segment_text)
11
- table_name = Util.first_word(table_specification)
12
+ stripped_specification = table_specification.strip
13
+
14
+ return parse_derived_table(stripped_specification) if stripped_specification.start_with?(Constants::OPEN_PARENTHESIS)
15
+
16
+ table_name = Util.first_word(stripped_specification)
12
17
  return unless table_name
13
18
 
14
- new(name: table_name, explicit_alias: extract_explicit_alias(table_specification))
19
+ new(name: table_name, explicit_alias: extract_explicit_alias(stripped_specification))
20
+ end
21
+
22
+ def self.parse_derived_table(table_specification)
23
+ closing_position = Scanner.new(table_specification).find_matching_parenthesis(0)
24
+ return unless closing_position
25
+
26
+ expression = table_specification[0..closing_position]
27
+ remaining = table_specification[(closing_position + 1)..].strip
28
+ derived_alias = extract_derived_table_alias(remaining)
29
+
30
+ new(
31
+ name: derived_alias || expression,
32
+ explicit_alias: derived_alias,
33
+ derived_table_expression: expression
34
+ )
15
35
  end
16
36
 
17
37
  def self.table_specification_text(segment_text)
@@ -32,6 +52,36 @@ module SqlBeautifier
32
52
  end
33
53
  end
34
54
 
55
+ def self.derived_table_lookup_name_from(text)
56
+ stripped_text = text.strip
57
+ return unless stripped_text.start_with?(Constants::OPEN_PARENTHESIS)
58
+
59
+ closing_position = Scanner.new(stripped_text).find_matching_parenthesis(0)
60
+ return unless closing_position
61
+
62
+ expression = stripped_text[0..closing_position]
63
+ remaining_text = stripped_text[(closing_position + 1)..].strip
64
+ derived_alias = extract_derived_table_alias(remaining_text)
65
+
66
+ derived_alias || expression
67
+ end
68
+
69
+ def self.extract_derived_table_alias(remaining_text)
70
+ return if remaining_text.empty?
71
+
72
+ words = remaining_text.split(Constants::WHITESPACE_REGEX)
73
+
74
+ if words.first&.downcase == "as"
75
+ words[1]
76
+ else
77
+ words.first
78
+ end
79
+ end
80
+
81
+ def derived_table?
82
+ @derived_table_expression.present?
83
+ end
84
+
35
85
  def formatted_name
36
86
  Util.format_table_name(@name)
37
87
  end
@@ -45,7 +95,14 @@ module SqlBeautifier
45
95
  end
46
96
 
47
97
  def render(trailing_sentinels: nil)
48
- formatted = alias_name ? "#{formatted_name} #{alias_name}" : formatted_name
98
+ formatted = begin
99
+ if derived_table?
100
+ alias_name ? "#{@derived_table_expression} #{alias_name}" : @derived_table_expression
101
+ else
102
+ alias_name ? "#{formatted_name} #{alias_name}" : formatted_name
103
+ end
104
+ end
105
+
49
106
  trailing_sentinels&.any? ? "#{formatted} #{trailing_sentinels.join(' ')}" : formatted
50
107
  end
51
108
  end
@@ -79,6 +79,11 @@ module SqlBeautifier
79
79
  next
80
80
  end
81
81
 
82
+ if reference.derived_table?
83
+ @references_by_name[reference.name] = reference
84
+ next
85
+ end
86
+
82
87
  initials = table_initials(reference.name)
83
88
  duplicate_initials_counts[initials] += 1 if initials_occurrence_counts[initials] > 1
84
89
 
@@ -109,7 +114,7 @@ module SqlBeautifier
109
114
  occurrence_counts = Hash.new(0)
110
115
 
111
116
  @references.each do |reference|
112
- next if reference.explicit_alias
117
+ next if reference.explicit_alias || reference.derived_table?
113
118
 
114
119
  occurrence_counts[table_initials(reference.name)] += 1
115
120
  end
@@ -136,16 +141,26 @@ module SqlBeautifier
136
141
  end
137
142
 
138
143
  def parse_references(from_content)
139
- split_segments = from_content.strip.split(Constants::JOIN_KEYWORD_PATTERN)
144
+ from_text = from_content.strip
145
+ join_positions = Tokenizer.find_all_top_level_join_positions(from_text)
140
146
 
141
147
  references = []
142
148
 
143
- primary_segment = split_segments.shift.strip
149
+ primary_end = join_positions.any? ? join_positions.first[:position] : from_text.length
150
+ primary_segment = from_text[0...primary_end].strip
144
151
  references << TableReference.parse(primary_segment)
145
152
 
146
- split_segments.each_slice(2) do |_join_keyword, join_content|
147
- next unless join_content
153
+ join_positions.each_with_index do |join_info, index|
154
+ content_start = join_info[:position] + join_info[:keyword].length
155
+ content_end = begin
156
+ if index + 1 < join_positions.length
157
+ join_positions[index + 1][:position]
158
+ else
159
+ from_text.length
160
+ end
161
+ end
148
162
 
163
+ join_content = from_text[content_start...content_end].strip
149
164
  references << TableReference.parse(join_content)
150
165
  end
151
166
 
@@ -173,6 +173,41 @@ module SqlBeautifier
173
173
  scanner.top_level?
174
174
  end
175
175
 
176
+ def find_all_top_level_join_positions(text)
177
+ positions = []
178
+ search_offset = 0
179
+
180
+ while search_offset < text.length
181
+ earliest_match = find_earliest_top_level_join_keyword(text, search_offset)
182
+ break unless earliest_match
183
+
184
+ positions << earliest_match
185
+ search_offset = earliest_match[:position] + earliest_match[:keyword].length
186
+ end
187
+
188
+ positions
189
+ end
190
+
191
+ def find_earliest_top_level_join_keyword(text, search_offset)
192
+ earliest_match = nil
193
+
194
+ Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
195
+ remaining_text = text[search_offset..]
196
+ keyword_position = find_top_level_keyword(remaining_text, keyword)
197
+ next unless keyword_position
198
+
199
+ absolute_position = search_offset + keyword_position
200
+ next if earliest_match && absolute_position >= earliest_match[:position]
201
+
202
+ earliest_match = {
203
+ position: absolute_position,
204
+ keyword: keyword,
205
+ }
206
+ end
207
+
208
+ earliest_match
209
+ end
210
+
176
211
  def scan_top_level_conjunctions(text)
177
212
  scanner = Scanner.new(text)
178
213
  conjunction_boundaries = []
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqlBeautifier
4
- VERSION = "0.9.1"
4
+ VERSION = "0.9.2"
5
5
  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.9.1
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kinnell Shah