sql_beautifier 0.9.2 → 0.10.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: bcf549a9025b3f1baa8e3e6586154ce0e4b9529985f8e54b49ff241e4a24cd19
4
- data.tar.gz: ce6eb31a10ad63436442bf0a27d4054857540a03d2b799ecf40db46246ac0852
3
+ metadata.gz: 9557199079511cec5188c694dd32e2d114aaedebb896b90dc4ebe9e95308f116
4
+ data.tar.gz: da7fe2c4ff35032c6908a8ea10b5e8c4482cb607a78ac3c241b7d4f2093bbc88
5
5
  SHA512:
6
- metadata.gz: f2bed68bdfff3e132819a44756f57da8b8e8445738d85e967ea98d108510afe4c2840f0f4a2ef592bf0cbbccac8facfaf247b31e2f8810c6e22d65c8ecee551d
7
- data.tar.gz: 595b15be6f7f1a6b7541580d679ef0bde692bca70e4cffec251ea4f995aa3f54c17305b28b2a62ee0172ed40ca1137a013f4041d5ecf2624bcd22a7563a52672
6
+ metadata.gz: d363570c1c0add348461b8b9069eb4f7075036140f6c7b86f03130c88903f5fcf17613123841a28ca28b55eeb6b3f16905572017222904a58595586dbb782134
7
+ data.tar.gz: da33c33ada19314d1421d7a1cab384b9f6fa25b456909f2086b662bf8c235089443a8513b95049bbebe7db70d4535d32cefec664de3927a0d5ca7a0f9da10c87
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.10.0] - 2026-03-30
6
+
7
+ - Add CASE expression formatting — searched CASE (`CASE WHEN ... THEN ... ELSE ... END`) and simple CASE (`CASE expr WHEN value THEN ... END`) are detected and formatted with consistent indentation of `when`/`else`/`end` lines relative to the `case` keyword
8
+ - Add CASE integration into SELECT columns, WHERE/HAVING conditions, and UPDATE SET assignments via `CaseExpression.format_in_text`
9
+ - Add inline vs expanded rendering for CASE expressions controlled by the existing `inline_group_threshold` configuration — short CASE expressions remain on a single line when below the threshold
10
+ - Add nested CASE support — inner CASE blocks within WHEN/THEN/ELSE values are recursively formatted with increased indentation
11
+ - Add string-literal and sentinel safety — CASE keywords inside quoted strings, double-quoted identifiers, and comment sentinels are not mistakenly parsed
12
+ - Preserve CASE expressions inside parenthesized function calls (e.g. `COALESCE(CASE ... END, 0)`) without top-level expansion
13
+ - Fix `AND`/`OR` inside CASE expressions being misidentified as top-level conjunctions in WHERE/HAVING clauses — conjunction scanning now tracks CASE/END depth
14
+ - Use `indent_spaces` configuration for CASE body indentation instead of a hard-coded value
15
+ - Add `INNER JOIN LATERAL` and `LEFT JOIN LATERAL` support — `LATERAL` is recognized as a join modifier rather than a table name, preserving correct derived table parsing and alias resolution for lateral subqueries
16
+ - Strip redundant outer parentheses from WHERE and HAVING clause bodies — `WHERE (active = true)`, `WHERE ((active = true))`, and `WHERE ((a = true) AND (b = true))` now correctly unwrap before formatting
17
+ - Fix `Condition.format` early return for single leaf conditions to use the unwrapped expression instead of the original text
18
+
5
19
  ## [0.9.2] - 2026-03-30
6
20
 
7
21
  - 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
data/README.md CHANGED
@@ -111,7 +111,7 @@ where u.active = true
111
111
  order by o.total desc;
112
112
  ```
113
113
 
114
- Supported join types: `inner join`, `left join`, `right join`, `full join`, `left outer join`, `right outer join`, `full outer join`, `cross join`.
114
+ Supported join types: `inner join`, `left join`, `right join`, `full join`, `left outer join`, `right outer join`, `full outer join`, `cross join`. The `LATERAL` modifier is supported with `inner join lateral` and `left join lateral` for lateral subqueries.
115
115
 
116
116
  ### DISTINCT and DISTINCT ON
117
117
 
@@ -212,6 +212,61 @@ group by status
212
212
  having count(*) > 5;
213
213
  ```
214
214
 
215
+ ### CASE Expressions
216
+
217
+ Both searched (`CASE WHEN ... THEN ... END`) and simple (`CASE expr WHEN value THEN ... END`) forms are formatted with multiline indentation. Inner `when`/`else`/`end` lines are indented relative to the `case` keyword:
218
+
219
+ ```ruby
220
+ SqlBeautifier.call("SELECT id, CASE WHEN status = 'active' THEN 'Active' WHEN status = 'pending' THEN 'Pending' ELSE 'Unknown' END AS status_label, name FROM users")
221
+ ```
222
+
223
+ Produces:
224
+
225
+ ```sql
226
+ select id,
227
+ case
228
+ when status = 'active' then 'Active'
229
+ when status = 'pending' then 'Pending'
230
+ else 'Unknown'
231
+ end as status_label,
232
+ name
233
+
234
+ from Users u;
235
+ ```
236
+
237
+ Simple CASE places the operand on the `case` line:
238
+
239
+ ```ruby
240
+ SqlBeautifier.call("SELECT CASE u.role WHEN 'admin' THEN 'Administrator' WHEN 'user' THEN 'Standard User' ELSE 'Guest' END AS role_label FROM users")
241
+ ```
242
+
243
+ Produces:
244
+
245
+ ```sql
246
+ select case u.role
247
+ when 'admin' then 'Administrator'
248
+ when 'user' then 'Standard User'
249
+ else 'Guest'
250
+ end as role_label
251
+
252
+ from Users u;
253
+ ```
254
+
255
+ CASE expressions inside parenthesized function calls are preserved inline:
256
+
257
+ ```ruby
258
+ SqlBeautifier.call("SELECT COALESCE(CASE WHEN x > 0 THEN x ELSE NULL END, 0) AS safe_x FROM users")
259
+ ```
260
+
261
+ Produces:
262
+
263
+ ```sql
264
+ select coalesce(case when x > 0 then x else null end, 0) as safe_x
265
+ from Users u;
266
+ ```
267
+
268
+ CASE expressions also work in WHERE/HAVING conditions and UPDATE SET assignments. Nested CASE blocks are recursively formatted with increased indentation. Short CASE expressions can remain inline when below the `inline_group_threshold`.
269
+
215
270
  ### LIMIT
216
271
 
217
272
  ```ruby
@@ -616,9 +671,9 @@ Controls how table names are formatted in the output. Default: `:pascal_case`.
616
671
 
617
672
  #### `inline_group_threshold`
618
673
 
619
- Maximum character length for a parenthesized condition group to remain on a single line. Groups whose inline representation exceeds this length are expanded to multiple lines with indented conditions. Default: `0` (always expand).
674
+ Maximum character length for a parenthesized condition group or CASE expression to remain on a single line. Groups and CASE expressions whose inline representation exceeds this length are expanded to multiple lines with indented contents. Default: `0` (always expand).
620
675
 
621
- Set to a positive integer to allow short groups to stay inline. For example, with a threshold of `80`, the group `(role = 'admin' or role = 'moderator')` would stay on one line since it's under 80 characters.
676
+ Set to a positive integer to allow short groups and CASE expressions to stay inline. For example, with a threshold of `80`, the group `(role = 'admin' or role = 'moderator')` and a short CASE like `case when x = 1 then 'yes' else 'no' end` would stay on one line since they're under 80 characters.
622
677
 
623
678
  #### `trailing_semicolon`
624
679
 
@@ -0,0 +1,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ class CaseExpression < Base
5
+ CASE_KEYWORD = "case"
6
+ WHEN_KEYWORD = "when"
7
+ THEN_KEYWORD = "then"
8
+ ELSE_KEYWORD = "else"
9
+ END_KEYWORD = "end"
10
+
11
+ option :operand, default: -> {}
12
+ option :when_clauses
13
+ option :else_value, default: -> {}
14
+ option :base_indent, default: -> { 0 }
15
+
16
+ def self.format_in_text(text, base_indent: 0)
17
+ output = +""
18
+ scanner = Scanner.new(text)
19
+
20
+ while scanner.position < text.length
21
+ consumed = scanner.scan_quoted_or_sentinel!
22
+ if consumed
23
+ output << consumed
24
+ next
25
+ end
26
+
27
+ if scanner.current_char == Constants::OPEN_PARENTHESIS
28
+ scanner.increment_depth!
29
+ output << scanner.current_char
30
+ scanner.advance!
31
+ next
32
+ end
33
+
34
+ if scanner.current_char == Constants::CLOSE_PARENTHESIS
35
+ scanner.decrement_depth!
36
+ output << scanner.current_char
37
+ scanner.advance!
38
+ next
39
+ end
40
+
41
+ if scanner.parenthesis_depth.zero? && scanner.keyword_at?(CASE_KEYWORD)
42
+ case_start = scanner.position
43
+ end_position = find_matching_end(text, case_start)
44
+
45
+ unless end_position
46
+ output << scanner.current_char
47
+ scanner.advance!
48
+ next
49
+ end
50
+
51
+ case_text = text[case_start...(end_position + END_KEYWORD.length)]
52
+ parsed = parse(case_text, base_indent: base_indent)
53
+ output << (parsed ? parsed.render : case_text)
54
+
55
+ scanner.advance!(end_position + END_KEYWORD.length - scanner.position)
56
+ next
57
+ end
58
+
59
+ output << scanner.current_char
60
+ scanner.advance!
61
+ end
62
+
63
+ output
64
+ end
65
+
66
+ def self.parse(text, base_indent: 0)
67
+ stripped = text.strip
68
+ scanner = Scanner.new(stripped)
69
+ return nil unless scanner.keyword_at?(CASE_KEYWORD)
70
+
71
+ scanner.skip_past_keyword!(CASE_KEYWORD)
72
+
73
+ operand = extract_operand(stripped, scanner)
74
+ when_clauses = []
75
+ else_value = nil
76
+ case_depth = 1
77
+
78
+ until scanner.finished?
79
+ consumed = scanner.scan_quoted_or_sentinel!
80
+ next if consumed
81
+
82
+ if scanner.current_char == Constants::OPEN_PARENTHESIS
83
+ scanner.increment_depth!
84
+ scanner.advance!
85
+ next
86
+ end
87
+
88
+ if scanner.current_char == Constants::CLOSE_PARENTHESIS
89
+ scanner.decrement_depth!
90
+ scanner.advance!
91
+ next
92
+ end
93
+
94
+ if scanner.parenthesis_depth.zero?
95
+ if scanner.keyword_at?(CASE_KEYWORD)
96
+ case_depth += 1
97
+ scanner.advance!(CASE_KEYWORD.length)
98
+ next
99
+ end
100
+
101
+ if scanner.keyword_at?(END_KEYWORD)
102
+ case_depth -= 1
103
+
104
+ if case_depth.zero?
105
+ return new(
106
+ operand: operand,
107
+ when_clauses: when_clauses,
108
+ else_value: else_value,
109
+ base_indent: base_indent
110
+ )
111
+ end
112
+
113
+ scanner.advance!(END_KEYWORD.length)
114
+ next
115
+ end
116
+
117
+ if case_depth == 1
118
+ if scanner.keyword_at?(WHEN_KEYWORD)
119
+ parsed_clause = parse_when_clause(stripped, scanner)
120
+ return nil unless parsed_clause
121
+
122
+ when_clauses << parsed_clause
123
+ next
124
+ end
125
+
126
+ if scanner.keyword_at?(ELSE_KEYWORD)
127
+ else_value = parse_else_value(stripped, scanner)
128
+ next
129
+ end
130
+ end
131
+ end
132
+
133
+ scanner.advance!
134
+ end
135
+
136
+ nil
137
+ end
138
+
139
+ def render
140
+ inline_version = render_inline
141
+ threshold = SqlBeautifier.config_for(:inline_group_threshold)
142
+
143
+ return inline_version if inline_version.length <= threshold
144
+
145
+ render_expanded
146
+ end
147
+
148
+ private
149
+
150
+ def render_inline
151
+ parts = +"case"
152
+ parts << " #{@operand}" if @operand
153
+
154
+ @when_clauses.each do |clause|
155
+ parts << " when #{clause[:condition]} then #{clause[:result]}"
156
+ end
157
+
158
+ parts << " else #{@else_value}" if @else_value
159
+ parts << " end"
160
+
161
+ parts
162
+ end
163
+
164
+ def render_expanded
165
+ indent_spaces = SqlBeautifier.config_for(:indent_spaces)
166
+ body_indent = Util.whitespace(@base_indent + indent_spaces)
167
+ closing_indent = Util.whitespace(@base_indent)
168
+ nested_base_indent = @base_indent + indent_spaces
169
+
170
+ lines = []
171
+
172
+ case_line = +"case"
173
+ case_line << " #{@operand}" if @operand
174
+ lines << case_line
175
+
176
+ @when_clauses.each do |clause|
177
+ formatted_condition = CaseExpression.format_in_text(clause[:condition], base_indent: nested_base_indent)
178
+ formatted_result = CaseExpression.format_in_text(clause[:result], base_indent: nested_base_indent)
179
+ lines << "#{body_indent}when #{formatted_condition} then #{formatted_result}"
180
+ end
181
+
182
+ if @else_value
183
+ formatted_else = CaseExpression.format_in_text(@else_value, base_indent: nested_base_indent)
184
+ lines << "#{body_indent}else #{formatted_else}"
185
+ end
186
+
187
+ lines << "#{closing_indent}end"
188
+
189
+ lines.join("\n")
190
+ end
191
+
192
+ def self.extract_operand(text, scanner)
193
+ start_position = scanner.position
194
+ operand_scanner = Scanner.new(text, position: start_position)
195
+
196
+ until operand_scanner.finished?
197
+ consumed = operand_scanner.scan_quoted_or_sentinel!
198
+ next if consumed
199
+
200
+ if operand_scanner.current_char == Constants::OPEN_PARENTHESIS
201
+ operand_scanner.increment_depth!
202
+ operand_scanner.advance!
203
+ next
204
+ end
205
+
206
+ if operand_scanner.current_char == Constants::CLOSE_PARENTHESIS
207
+ operand_scanner.decrement_depth!
208
+ operand_scanner.advance!
209
+ next
210
+ end
211
+
212
+ if operand_scanner.parenthesis_depth.zero? && operand_scanner.keyword_at?(WHEN_KEYWORD)
213
+ operand_text = text[start_position...operand_scanner.position].strip
214
+ scanner.advance!(operand_scanner.position - scanner.position)
215
+
216
+ return operand_text.empty? ? nil : operand_text
217
+ end
218
+
219
+ operand_scanner.advance!
220
+ end
221
+
222
+ scanner.advance!(operand_scanner.position - scanner.position)
223
+ nil
224
+ end
225
+
226
+ def self.parse_when_clause(text, scanner)
227
+ scanner.skip_past_keyword!(WHEN_KEYWORD)
228
+ condition_start = scanner.position
229
+ case_depth = 0
230
+
231
+ until scanner.finished?
232
+ consumed = scanner.scan_quoted_or_sentinel!
233
+ next if consumed
234
+
235
+ if scanner.current_char == Constants::OPEN_PARENTHESIS
236
+ scanner.increment_depth!
237
+ scanner.advance!
238
+ next
239
+ end
240
+
241
+ if scanner.current_char == Constants::CLOSE_PARENTHESIS
242
+ scanner.decrement_depth!
243
+ scanner.advance!
244
+ next
245
+ end
246
+
247
+ if scanner.parenthesis_depth.zero?
248
+ if scanner.keyword_at?(CASE_KEYWORD)
249
+ case_depth += 1
250
+ scanner.advance!(CASE_KEYWORD.length)
251
+ next
252
+ end
253
+
254
+ if scanner.keyword_at?(END_KEYWORD) && case_depth.positive?
255
+ case_depth -= 1
256
+ scanner.advance!(END_KEYWORD.length)
257
+ next
258
+ end
259
+
260
+ if case_depth.zero? && scanner.keyword_at?(THEN_KEYWORD)
261
+ condition = text[condition_start...scanner.position].strip
262
+ scanner.skip_past_keyword!(THEN_KEYWORD)
263
+ then_result = parse_then_result(text, scanner)
264
+
265
+ return { condition: condition, result: then_result }
266
+ end
267
+ end
268
+
269
+ scanner.advance!
270
+ end
271
+
272
+ nil
273
+ end
274
+
275
+ def self.parse_then_result(text, scanner)
276
+ result_start = scanner.position
277
+ case_depth = 0
278
+
279
+ until scanner.finished?
280
+ consumed = scanner.scan_quoted_or_sentinel!
281
+ next if consumed
282
+
283
+ if scanner.current_char == Constants::OPEN_PARENTHESIS
284
+ scanner.increment_depth!
285
+ scanner.advance!
286
+ next
287
+ end
288
+
289
+ if scanner.current_char == Constants::CLOSE_PARENTHESIS
290
+ scanner.decrement_depth!
291
+ scanner.advance!
292
+ next
293
+ end
294
+
295
+ if scanner.parenthesis_depth.zero?
296
+ if scanner.keyword_at?(CASE_KEYWORD)
297
+ case_depth += 1
298
+ scanner.advance!(CASE_KEYWORD.length)
299
+ next
300
+ end
301
+
302
+ if scanner.keyword_at?(END_KEYWORD) && case_depth.positive?
303
+ case_depth -= 1
304
+ scanner.advance!(END_KEYWORD.length)
305
+ next
306
+ end
307
+
308
+ return text[result_start...scanner.position].strip if case_depth.zero? && (scanner.keyword_at?(WHEN_KEYWORD) || scanner.keyword_at?(ELSE_KEYWORD) || scanner.keyword_at?(END_KEYWORD))
309
+ end
310
+
311
+ scanner.advance!
312
+ end
313
+
314
+ text[result_start...scanner.position].strip
315
+ end
316
+
317
+ def self.parse_else_value(text, scanner)
318
+ scanner.skip_past_keyword!(ELSE_KEYWORD)
319
+ else_start = scanner.position
320
+ case_depth = 0
321
+
322
+ until scanner.finished?
323
+ consumed = scanner.scan_quoted_or_sentinel!
324
+ next if consumed
325
+
326
+ if scanner.current_char == Constants::OPEN_PARENTHESIS
327
+ scanner.increment_depth!
328
+ scanner.advance!
329
+ next
330
+ end
331
+
332
+ if scanner.current_char == Constants::CLOSE_PARENTHESIS
333
+ scanner.decrement_depth!
334
+ scanner.advance!
335
+ next
336
+ end
337
+
338
+ if scanner.parenthesis_depth.zero?
339
+ if scanner.keyword_at?(CASE_KEYWORD)
340
+ case_depth += 1
341
+ scanner.advance!(CASE_KEYWORD.length)
342
+ next
343
+ end
344
+
345
+ if scanner.keyword_at?(END_KEYWORD) && case_depth.positive?
346
+ case_depth -= 1
347
+ scanner.advance!(END_KEYWORD.length)
348
+ next
349
+ end
350
+
351
+ return text[else_start...scanner.position].strip if case_depth.zero? && scanner.keyword_at?(END_KEYWORD)
352
+ end
353
+
354
+ scanner.advance!
355
+ end
356
+
357
+ text[else_start...scanner.position].strip
358
+ end
359
+
360
+ def self.find_matching_end(text, case_start)
361
+ scanner = Scanner.new(text, position: case_start)
362
+ scanner.advance!(CASE_KEYWORD.length)
363
+ case_depth = 1
364
+
365
+ until scanner.finished?
366
+ consumed = scanner.scan_quoted_or_sentinel!
367
+ next if consumed
368
+
369
+ if scanner.current_char == Constants::OPEN_PARENTHESIS
370
+ scanner.increment_depth!
371
+ scanner.advance!
372
+ next
373
+ end
374
+
375
+ if scanner.current_char == Constants::CLOSE_PARENTHESIS
376
+ scanner.decrement_depth!
377
+ scanner.advance!
378
+ next
379
+ end
380
+
381
+ if scanner.parenthesis_depth.zero?
382
+ if scanner.keyword_at?(CASE_KEYWORD)
383
+ case_depth += 1
384
+ scanner.advance!(CASE_KEYWORD.length)
385
+ next
386
+ end
387
+
388
+ if scanner.keyword_at?(END_KEYWORD)
389
+ case_depth -= 1
390
+ return scanner.position if case_depth.zero?
391
+
392
+ scanner.advance!(END_KEYWORD.length)
393
+ next
394
+ end
395
+ end
396
+
397
+ scanner.advance!
398
+ end
399
+
400
+ nil
401
+ end
402
+
403
+ private_class_method :extract_operand, :parse_when_clause, :parse_then_result, :parse_else_value, :find_matching_end
404
+ end
405
+ end
@@ -4,16 +4,26 @@ module SqlBeautifier
4
4
  module Clauses
5
5
  class ConditionClause < Base
6
6
  def call
7
- return "#{keyword_prefix}#{@value.strip}" unless multiple_conditions?
7
+ keyword_width = SqlBeautifier.config_for(:keyword_column_width)
8
+ formatted_value = CaseExpression.format_in_text(@value, base_indent: keyword_width)
9
+ unwrapped_value = strip_wrapping_parentheses(formatted_value)
8
10
 
9
- formatted_conditions = Condition.format(@value, indent_width: SqlBeautifier.config_for(:keyword_column_width))
11
+ return "#{keyword_prefix}#{unwrapped_value}" unless multiple_conditions?(unwrapped_value)
12
+
13
+ formatted_conditions = Condition.format(unwrapped_value, indent_width: keyword_width)
10
14
  formatted_conditions.sub(continuation_indent, keyword_prefix)
11
15
  end
12
16
 
13
17
  private
14
18
 
15
- def multiple_conditions?
16
- Tokenizer.split_top_level_conditions(@value).length > 1
19
+ def strip_wrapping_parentheses(text)
20
+ output = text.strip
21
+ output = Util.strip_outer_parentheses(output) while Tokenizer.outer_parentheses_wrap_all?(output)
22
+ output
23
+ end
24
+
25
+ def multiple_conditions?(text)
26
+ Tokenizer.split_top_level_conditions(text).length > 1
17
27
  end
18
28
  end
19
29
  end
@@ -21,8 +21,11 @@ module SqlBeautifier
21
21
  private
22
22
 
23
23
  def parse_expressions(value)
24
+ keyword_width = SqlBeautifier.config_for(:keyword_column_width)
25
+
24
26
  Tokenizer.split_by_top_level_commas(value).map do |column|
25
- Expression.parse(column)
27
+ formatted_column = CaseExpression.format_in_text(column, base_indent: keyword_width)
28
+ Expression.parse(formatted_column)
26
29
  end
27
30
  end
28
31
 
@@ -8,7 +8,7 @@ module SqlBeautifier
8
8
 
9
9
  def self.format(text, indent_width: 0)
10
10
  conditions = parse_all(text)
11
- return text.strip if conditions.length <= 1 && conditions.first&.leaf?
11
+ return conditions.first.expression if conditions.length <= 1 && conditions.first&.leaf?
12
12
 
13
13
  render_all(conditions, indent_width: indent_width)
14
14
  end
@@ -42,6 +42,8 @@ module SqlBeautifier
42
42
  CLOSE_PARENTHESIS = ")"
43
43
  COMMA = ","
44
44
 
45
+ LATERAL_PREFIX_PATTERN = %r{\Alateral\s+}i
46
+
45
47
  WHITESPACE_REGEX = %r{\s+}
46
48
  WHITESPACE_CHARACTER_REGEX = %r{\s}
47
49
  SINGLE_QUOTE = "'"
@@ -2,7 +2,13 @@
2
2
 
3
3
  module SqlBeautifier
4
4
  class CreateTableAs < Base
5
- MODIFIERS = %w[temp temporary unlogged local].freeze
5
+ MODIFIERS = %w[
6
+ temp
7
+ temporary
8
+ unlogged
9
+ local
10
+ ].freeze
11
+
6
12
  WITH_DATA_SUFFIX_REGEX = %r{\s+(with\s+(?:no\s+)?data)\s*\z}i
7
13
 
8
14
  option :modifier, default: -> {}
@@ -3,6 +3,7 @@
3
3
  module SqlBeautifier
4
4
  class Join < Base
5
5
  option :keyword
6
+ option :lateral, default: -> { false }
6
7
  option :table_reference
7
8
  option :trailing_sentinels, default: -> {}
8
9
  option :conditions, default: -> { [] }
@@ -11,6 +12,8 @@ module SqlBeautifier
11
12
  keyword, remaining_content = extract_keyword(join_text)
12
13
  return unless keyword && remaining_content
13
14
 
15
+ remaining_content, lateral = TableReference.strip_lateral_prefix(remaining_content)
16
+
14
17
  on_keyword_position = Tokenizer.find_top_level_keyword(remaining_content, "on")
15
18
 
16
19
  if on_keyword_position
@@ -28,7 +31,7 @@ module SqlBeautifier
28
31
 
29
32
  trailing_sentinels = extract_trailing_sentinels(table_text)
30
33
 
31
- new(keyword: keyword, table_reference: table_reference, trailing_sentinels: trailing_sentinels, conditions: conditions)
34
+ new(keyword: keyword, lateral: lateral, table_reference: table_reference, trailing_sentinels: trailing_sentinels, conditions: conditions)
32
35
  end
33
36
 
34
37
  def self.extract_keyword(join_text)
@@ -52,17 +55,18 @@ module SqlBeautifier
52
55
 
53
56
  def render(continuation_indent:, condition_indent:)
54
57
  rendered_table = @table_reference.render(trailing_sentinels: @trailing_sentinels)
58
+ lateral_prefix = @lateral ? "lateral " : ""
55
59
  lines = []
56
60
 
57
61
  if @conditions.any?
58
62
  first_condition = @conditions.first[1]
59
- lines << "#{continuation_indent}#{@keyword} #{rendered_table} on #{first_condition}"
63
+ lines << "#{continuation_indent}#{@keyword} #{lateral_prefix}#{rendered_table} on #{first_condition}"
60
64
 
61
65
  @conditions.drop(1).each do |conjunction, condition|
62
66
  lines << "#{condition_indent}#{conjunction} #{condition}"
63
67
  end
64
68
  else
65
- lines << "#{continuation_indent}#{@keyword} #{rendered_table}"
69
+ lines << "#{continuation_indent}#{@keyword} #{lateral_prefix}#{rendered_table}"
66
70
  end
67
71
 
68
72
  lines.join("\n")
@@ -2,9 +2,28 @@
2
2
 
3
3
  module SqlBeautifier
4
4
  module StatementSplitter
5
- STATEMENT_KEYWORDS = %w[select with create insert update delete].freeze
6
- BOUNDARY_KEYWORDS = %w[from where having limit into set values].freeze
7
- CONTINUATION_PAIRS = { "insert" => "select" }.freeze
5
+ STATEMENT_KEYWORDS = %w[
6
+ select
7
+ with
8
+ create
9
+ insert
10
+ update
11
+ delete
12
+ ].freeze
13
+
14
+ BOUNDARY_KEYWORDS = %w[
15
+ from
16
+ where
17
+ having
18
+ limit
19
+ into
20
+ set
21
+ values
22
+ ].freeze
23
+
24
+ CONTINUATION_PAIRS = {
25
+ "insert" => "select",
26
+ }.freeze
8
27
 
9
28
  module_function
10
29
 
@@ -9,7 +9,7 @@ module SqlBeautifier
9
9
 
10
10
  def self.parse(segment_text)
11
11
  table_specification = table_specification_text(segment_text)
12
- stripped_specification = table_specification.strip
12
+ stripped_specification, _lateral = strip_lateral_prefix(table_specification.strip)
13
13
 
14
14
  return parse_derived_table(stripped_specification) if stripped_specification.start_with?(Constants::OPEN_PARENTHESIS)
15
15
 
@@ -52,8 +52,18 @@ module SqlBeautifier
52
52
  end
53
53
  end
54
54
 
55
+ def self.strip_lateral_prefix(text)
56
+ stripped = text.strip
57
+
58
+ if stripped.match?(Constants::LATERAL_PREFIX_PATTERN)
59
+ [stripped.sub(Constants::LATERAL_PREFIX_PATTERN, ""), true]
60
+ else
61
+ [stripped, false]
62
+ end
63
+ end
64
+
55
65
  def self.derived_table_lookup_name_from(text)
56
- stripped_text = text.strip
66
+ stripped_text, _lateral = strip_lateral_prefix(text.strip)
57
67
  return unless stripped_text.start_with?(Constants::OPEN_PARENTHESIS)
58
68
 
59
69
  closing_position = Scanner.new(stripped_text).find_matching_parenthesis(0)
@@ -9,6 +9,7 @@ module SqlBeautifier
9
9
  @alias_strategy = SqlBeautifier.config_for(:alias_strategy)
10
10
  @references = []
11
11
  @references_by_name = {}
12
+
12
13
  build!
13
14
  end
14
15
 
@@ -212,6 +212,7 @@ module SqlBeautifier
212
212
  scanner = Scanner.new(text)
213
213
  conjunction_boundaries = []
214
214
  inside_between = false
215
+ case_depth = 0
215
216
 
216
217
  until scanner.finished?
217
218
  next if scanner.skip_quoted_or_sentinel!
@@ -229,23 +230,37 @@ module SqlBeautifier
229
230
  scanner.advance!
230
231
  else
231
232
  if scanner.parenthesis_depth.zero?
232
- inside_between = true if scanner.keyword_at?(Constants::BETWEEN_KEYWORD)
233
-
234
- matched_conjunction = scanner.detect_conjunction_at
235
-
236
- if matched_conjunction
237
- if matched_conjunction == "and" && inside_between
238
- inside_between = false
239
- else
240
- conjunction_boundaries << {
241
- conjunction: matched_conjunction,
242
- position: scanner.position,
243
- }
244
- end
233
+ if scanner.keyword_at?("case")
234
+ case_depth += 1
235
+ scanner.advance!("case".length)
236
+ next
237
+ end
245
238
 
246
- scanner.advance!(matched_conjunction.length)
239
+ if scanner.keyword_at?("end") && case_depth.positive?
240
+ case_depth -= 1
241
+ scanner.advance!("end".length)
247
242
  next
248
243
  end
244
+
245
+ if case_depth.zero?
246
+ inside_between = true if scanner.keyword_at?(Constants::BETWEEN_KEYWORD)
247
+
248
+ matched_conjunction = scanner.detect_conjunction_at
249
+
250
+ if matched_conjunction
251
+ if matched_conjunction == "and" && inside_between
252
+ inside_between = false
253
+ else
254
+ conjunction_boundaries << {
255
+ conjunction: matched_conjunction,
256
+ position: scanner.position,
257
+ }
258
+ end
259
+
260
+ scanner.advance!(matched_conjunction.length)
261
+ next
262
+ end
263
+ end
249
264
  end
250
265
 
251
266
  scanner.advance!
@@ -92,12 +92,15 @@ module SqlBeautifier
92
92
  def render_assignments
93
93
  items = Tokenizer.split_by_top_level_commas(@assignments)
94
94
  continuation = Util.continuation_padding
95
+ keyword_width = SqlBeautifier.config_for(:keyword_column_width)
95
96
 
96
97
  formatted_items = items.map.with_index do |item, index|
98
+ formatted_item = CaseExpression.format_in_text(item.strip, base_indent: keyword_width)
99
+
97
100
  if index.zero?
98
- "\n#{Util.keyword_padding('set')}#{item.strip}"
101
+ "\n#{Util.keyword_padding('set')}#{formatted_item}"
99
102
  else
100
- "\n#{continuation}#{item.strip}"
103
+ "\n#{continuation}#{formatted_item}"
101
104
  end
102
105
  end
103
106
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqlBeautifier
4
- VERSION = "0.9.2"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -18,6 +18,7 @@ require_relative "sql_beautifier/statement_splitter"
18
18
  require_relative "sql_beautifier/table_reference"
19
19
  require_relative "sql_beautifier/table_registry"
20
20
  require_relative "sql_beautifier/join"
21
+ require_relative "sql_beautifier/case_expression"
21
22
  require_relative "sql_beautifier/expression"
22
23
  require_relative "sql_beautifier/sort_expression"
23
24
  require_relative "sql_beautifier/condition"
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.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kinnell Shah
@@ -66,6 +66,7 @@ files:
66
66
  - README.md
67
67
  - lib/sql_beautifier.rb
68
68
  - lib/sql_beautifier/base.rb
69
+ - lib/sql_beautifier/case_expression.rb
69
70
  - lib/sql_beautifier/clauses/base.rb
70
71
  - lib/sql_beautifier/clauses/condition_clause.rb
71
72
  - lib/sql_beautifier/clauses/from.rb