sql_beautifier 0.1.3 → 0.2.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 +13 -3
- data/README.md +143 -7
- data/lib/sql_beautifier/clauses/condition_clause.rb +20 -0
- data/lib/sql_beautifier/clauses/from.rb +136 -1
- data/lib/sql_beautifier/clauses/having.rb +1 -5
- data/lib/sql_beautifier/clauses/select.rb +60 -5
- data/lib/sql_beautifier/clauses/where.rb +1 -5
- data/lib/sql_beautifier/condition_formatter.rb +126 -0
- data/lib/sql_beautifier/constants.rb +32 -0
- data/lib/sql_beautifier/formatter.rb +10 -1
- data/lib/sql_beautifier/normalizer.rb +13 -15
- data/lib/sql_beautifier/table_registry.rb +215 -0
- data/lib/sql_beautifier/tokenizer.rb +223 -20
- data/lib/sql_beautifier/util.rb +34 -0
- data/lib/sql_beautifier/version.rb +1 -1
- data/lib/sql_beautifier.rb +5 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d52933edcc70d3aaa5ed46f2c5d3bc6ac0a285294d84b25aa95c4e397fcb1d79
|
|
4
|
+
data.tar.gz: 5411d9f654c2d73fad75289e15433f47c34a45f463eae15fb1d832306fb62bd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbb3edc9cbe4e663ee167fda3632b12ffff04e5eddc6f225728abad772be643c7c6c97755ca4662210b380489ab5c2acf39968457fbc49042079e39eec5b120a
|
|
7
|
+
data.tar.gz: 5822d3514ad54619848b8e90cce8d5c4989cd2d367a20df00d51d3c03bf07a34b55166757ae72694970583a28db840a3234d7a309520673d2afc8e0d6a98e6f8
|
data/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [X.X.X] - YYYY-MM-DD
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.2.0] - 2026-03-27
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- Add JOIN support (inner, left, right, full outer, cross) with formatted continuation lines
|
|
8
|
+
- Add automatic table aliasing using initials (e.g. `users` → `u`, `active_storage_blobs` → `asb`)
|
|
9
|
+
- Add PascalCase table name formatting (e.g. `users` → `Users`, `user_sessions` → `User_Sessions`)
|
|
10
|
+
- Add `table.column` → `alias.column` replacement across full output
|
|
11
|
+
- Add DISTINCT and DISTINCT ON support in SELECT clause
|
|
12
|
+
- Add AND/OR condition formatting in WHERE and HAVING clauses with per-line indentation
|
|
13
|
+
- Add parenthesized condition group handling (inline when short, expanded when long)
|
|
8
14
|
|
|
9
|
-
## [0.1.
|
|
15
|
+
## [0.1.4] - 2026-03-26
|
|
16
|
+
|
|
17
|
+
- Update `bin/ci` and `bin/release` to use new formatting functions
|
|
18
|
+
- Add `--quiet` flag to `bin/ci` to suppress output
|
|
19
|
+
- Add `--dry-run` flag to `bin/release` to perform a dry run of the release process
|
|
10
20
|
|
|
11
21
|
## [0.1.0] - 2026-03-26
|
|
12
22
|
|
data/README.md
CHANGED
|
@@ -41,14 +41,149 @@ select id,
|
|
|
41
41
|
name,
|
|
42
42
|
email
|
|
43
43
|
|
|
44
|
-
from
|
|
44
|
+
from Users u
|
|
45
45
|
|
|
46
46
|
where active = true
|
|
47
47
|
|
|
48
48
|
order by name
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
Single-word keywords are lowercased and padded so their clause bodies start at an 8-character column. Multi-word clauses such as `order by` and `group by`, and short clauses like `limit`, use a single space between the keyword and the clause body instead of padding. Each clause is separated by a blank line. Multi-column SELECT lists place each column on its own line with continuation indentation.
|
|
51
|
+
Single-word keywords are lowercased and padded so their clause bodies start at an 8-character column. Multi-word clauses such as `order by` and `group by`, and short clauses like `limit`, use a single space between the keyword and the clause body instead of padding. Each clause is separated by a blank line. Multi-column SELECT lists place each column on its own line with continuation indentation. Table names are PascalCased and automatically aliased.
|
|
52
|
+
|
|
53
|
+
### Table Aliasing
|
|
54
|
+
|
|
55
|
+
Tables are automatically aliased using their initials. Underscore-separated table names use the first letter of each segment:
|
|
56
|
+
|
|
57
|
+
| Table Name | PascalCase | Alias |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `users` | `Users` | `u` |
|
|
60
|
+
| `active_storage_blobs` | `Active_Storage_Blobs` | `asb` |
|
|
61
|
+
| `person_event_invitations` | `Person_Event_Invitations` | `pei` |
|
|
62
|
+
|
|
63
|
+
All `table.column` references throughout the query are replaced with `alias.column`:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
SqlBeautifier.call("SELECT users.id, users.name FROM users WHERE users.active = true")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Produces:
|
|
70
|
+
|
|
71
|
+
```sql
|
|
72
|
+
select u.id,
|
|
73
|
+
u.name
|
|
74
|
+
|
|
75
|
+
from Users u
|
|
76
|
+
|
|
77
|
+
where u.active = true
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When two tables produce the same initials, a counter is appended for disambiguation (e.g. `u1`, `u2`).
|
|
81
|
+
|
|
82
|
+
### JOINs
|
|
83
|
+
|
|
84
|
+
JOIN clauses are formatted on continuation-indented lines with PascalCase table names and aliases. Multi-condition JOINs place additional conditions on further-indented lines:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
SqlBeautifier.call(<<~SQL)
|
|
88
|
+
SELECT users.id, orders.total, products.name
|
|
89
|
+
FROM users
|
|
90
|
+
INNER JOIN orders ON orders.user_id = users.id
|
|
91
|
+
INNER JOIN products ON products.id = orders.product_id
|
|
92
|
+
WHERE users.active = true AND orders.total > 100
|
|
93
|
+
ORDER BY orders.total DESC
|
|
94
|
+
SQL
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Produces:
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
select u.id,
|
|
101
|
+
o.total,
|
|
102
|
+
p.name
|
|
103
|
+
|
|
104
|
+
from Users u
|
|
105
|
+
inner join Orders o on o.user_id = u.id
|
|
106
|
+
inner join Products p on p.id = o.product_id
|
|
107
|
+
|
|
108
|
+
where u.active = true
|
|
109
|
+
and o.total > 100
|
|
110
|
+
|
|
111
|
+
order by o.total desc
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Supported join types: `inner join`, `left join`, `right join`, `full join`, `left outer join`, `right outer join`, `full outer join`, `cross join`.
|
|
115
|
+
|
|
116
|
+
### DISTINCT and DISTINCT ON
|
|
117
|
+
|
|
118
|
+
`DISTINCT` is placed on the `select` line as a modifier, with columns on continuation lines:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
SqlBeautifier.call("SELECT DISTINCT id, name, email FROM users")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Produces:
|
|
125
|
+
|
|
126
|
+
```sql
|
|
127
|
+
select distinct
|
|
128
|
+
id,
|
|
129
|
+
name,
|
|
130
|
+
email
|
|
131
|
+
|
|
132
|
+
from Users u
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`DISTINCT ON` preserves the full expression:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
SqlBeautifier.call("SELECT DISTINCT ON (user_id) id, name FROM events")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Produces:
|
|
142
|
+
|
|
143
|
+
```sql
|
|
144
|
+
select distinct on (user_id)
|
|
145
|
+
id,
|
|
146
|
+
name
|
|
147
|
+
|
|
148
|
+
from Events e
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### WHERE and HAVING Conditions
|
|
152
|
+
|
|
153
|
+
Multiple conditions in WHERE and HAVING clauses are formatted with each condition on its own line:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
SqlBeautifier.call("SELECT * FROM users WHERE active = true AND role = 'admin' AND created_at > '2024-01-01'")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Produces:
|
|
160
|
+
|
|
161
|
+
```sql
|
|
162
|
+
select *
|
|
163
|
+
|
|
164
|
+
from Users u
|
|
165
|
+
|
|
166
|
+
where active = true
|
|
167
|
+
and role = 'admin'
|
|
168
|
+
and created_at > '2024-01-01'
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Short parenthesized groups stay inline:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
SqlBeautifier.call("SELECT * FROM users WHERE active = true AND (role = 'admin' OR role = 'moderator')")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Produces:
|
|
178
|
+
|
|
179
|
+
```sql
|
|
180
|
+
select *
|
|
181
|
+
|
|
182
|
+
from Users u
|
|
183
|
+
|
|
184
|
+
where active = true
|
|
185
|
+
and (role = 'admin' or role = 'moderator')
|
|
186
|
+
```
|
|
52
187
|
|
|
53
188
|
### GROUP BY and HAVING
|
|
54
189
|
|
|
@@ -67,7 +202,7 @@ Produces:
|
|
|
67
202
|
select status,
|
|
68
203
|
count(*)
|
|
69
204
|
|
|
70
|
-
from
|
|
205
|
+
from Users u
|
|
71
206
|
|
|
72
207
|
group by status
|
|
73
208
|
|
|
@@ -85,7 +220,7 @@ Produces:
|
|
|
85
220
|
```sql
|
|
86
221
|
select id
|
|
87
222
|
|
|
88
|
-
from
|
|
223
|
+
from Users u
|
|
89
224
|
|
|
90
225
|
order by created_at desc
|
|
91
226
|
|
|
@@ -105,9 +240,10 @@ Produces:
|
|
|
105
240
|
```sql
|
|
106
241
|
select *
|
|
107
242
|
|
|
108
|
-
from
|
|
243
|
+
from Users u
|
|
109
244
|
|
|
110
|
-
where name = 'O''Brien'
|
|
245
|
+
where name = 'O''Brien'
|
|
246
|
+
and status = 'Active'
|
|
111
247
|
```
|
|
112
248
|
|
|
113
249
|
### Double-Quoted Identifiers
|
|
@@ -124,7 +260,7 @@ Produces:
|
|
|
124
260
|
select user_id,
|
|
125
261
|
full_name
|
|
126
262
|
|
|
127
|
-
from
|
|
263
|
+
from Users u
|
|
128
264
|
```
|
|
129
265
|
|
|
130
266
|
### Callable Interface
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlBeautifier
|
|
4
|
+
module Clauses
|
|
5
|
+
class ConditionClause < Base
|
|
6
|
+
def call
|
|
7
|
+
return "#{self.class::KEYWORD_PREFIX}#{@value.strip}" unless multiple_conditions?
|
|
8
|
+
|
|
9
|
+
formatted_conditions = ConditionFormatter.format(@value, indent_width: Constants::KEYWORD_COLUMN_WIDTH)
|
|
10
|
+
formatted_conditions.sub(Constants::LEADING_KEYWORD_INDENT_PATTERN, self.class::KEYWORD_PREFIX)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def multiple_conditions?
|
|
16
|
+
Tokenizer.split_top_level_conditions(@value).length > 1
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -4,9 +4,144 @@ module SqlBeautifier
|
|
|
4
4
|
module Clauses
|
|
5
5
|
class From < Base
|
|
6
6
|
KEYWORD_PREFIX = "from "
|
|
7
|
+
CONTINUATION_INDENTATION = " "
|
|
8
|
+
JOIN_CONDITION_INDENTATION = " "
|
|
9
|
+
|
|
10
|
+
def self.call(value, table_registry:)
|
|
11
|
+
new(value, table_registry: table_registry).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(value, table_registry:)
|
|
15
|
+
super(value)
|
|
16
|
+
@table_registry = table_registry
|
|
17
|
+
end
|
|
7
18
|
|
|
8
19
|
def call
|
|
9
|
-
|
|
20
|
+
@lines = []
|
|
21
|
+
|
|
22
|
+
join_parts = split_join_parts
|
|
23
|
+
primary_table_text = join_parts.shift.strip
|
|
24
|
+
formatted_primary_table_name = format_table_with_alias(primary_table_text)
|
|
25
|
+
add_line!("#{KEYWORD_PREFIX}#{formatted_primary_table_name}")
|
|
26
|
+
|
|
27
|
+
join_parts.each { |join_part| format_join_part(join_part) }
|
|
28
|
+
|
|
29
|
+
@lines.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def add_line!(line)
|
|
35
|
+
@lines << line
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_join_part(join_part)
|
|
39
|
+
join_keyword, remaining_join_content = extract_join_keyword(join_part)
|
|
40
|
+
return unless join_keyword && remaining_join_content
|
|
41
|
+
|
|
42
|
+
on_keyword_position = Tokenizer.find_top_level_keyword(remaining_join_content, "on")
|
|
43
|
+
|
|
44
|
+
if on_keyword_position
|
|
45
|
+
format_join_with_conditions(join_keyword, remaining_join_content, on_keyword_position)
|
|
46
|
+
else
|
|
47
|
+
formatted_table_name = format_table_with_alias(remaining_join_content)
|
|
48
|
+
add_line!("#{CONTINUATION_INDENTATION}#{join_keyword} #{formatted_table_name}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def format_join_with_conditions(join_keyword, join_content, on_keyword_position)
|
|
53
|
+
table_text = join_content[0...on_keyword_position].strip
|
|
54
|
+
condition_text = join_content[on_keyword_position..].delete_prefix("on").strip
|
|
55
|
+
on_conditions = Tokenizer.split_top_level_conditions(condition_text)
|
|
56
|
+
|
|
57
|
+
formatted_table_name = format_table_with_alias(table_text)
|
|
58
|
+
first_condition = on_conditions.first[1]
|
|
59
|
+
add_line!("#{CONTINUATION_INDENTATION}#{join_keyword} #{formatted_table_name} on #{first_condition}")
|
|
60
|
+
|
|
61
|
+
on_conditions.drop(1).each do |conjunction, additional_condition|
|
|
62
|
+
add_line!("#{JOIN_CONDITION_INDENTATION}#{conjunction} #{additional_condition}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def split_join_parts
|
|
67
|
+
from_content = @value.strip
|
|
68
|
+
join_keyword_positions = find_all_join_keyword_positions(from_content)
|
|
69
|
+
|
|
70
|
+
return [from_content] if join_keyword_positions.empty?
|
|
71
|
+
|
|
72
|
+
parts = [from_content[0...join_keyword_positions.first[:position]]]
|
|
73
|
+
|
|
74
|
+
join_keyword_positions.each_with_index do |join_info, index|
|
|
75
|
+
end_position = begin
|
|
76
|
+
if index + 1 < join_keyword_positions.length
|
|
77
|
+
join_keyword_positions[index + 1][:position]
|
|
78
|
+
else
|
|
79
|
+
from_content.length
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parts << from_content[join_info[:position]...end_position]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
parts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find_all_join_keyword_positions(text)
|
|
90
|
+
positions = []
|
|
91
|
+
search_offset = 0
|
|
92
|
+
|
|
93
|
+
while search_offset < text.length
|
|
94
|
+
earliest_match = find_earliest_join_keyword(text, search_offset)
|
|
95
|
+
break unless earliest_match
|
|
96
|
+
|
|
97
|
+
positions << earliest_match
|
|
98
|
+
search_offset = earliest_match[:position] + earliest_match[:keyword].length
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
positions
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_earliest_join_keyword(text, search_offset)
|
|
105
|
+
earliest_match = nil
|
|
106
|
+
|
|
107
|
+
Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
|
|
108
|
+
remaining_text = text[search_offset..]
|
|
109
|
+
keyword_position = Tokenizer.find_top_level_keyword(remaining_text, keyword)
|
|
110
|
+
next unless keyword_position
|
|
111
|
+
|
|
112
|
+
absolute_position = search_offset + keyword_position
|
|
113
|
+
next if earliest_match && absolute_position >= earliest_match[:position]
|
|
114
|
+
|
|
115
|
+
earliest_match = {
|
|
116
|
+
position: absolute_position,
|
|
117
|
+
keyword: keyword,
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
earliest_match
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_join_keyword(join_part)
|
|
125
|
+
trimmed_join_text = join_part.strip
|
|
126
|
+
|
|
127
|
+
Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
|
|
128
|
+
next unless trimmed_join_text.downcase.start_with?(keyword)
|
|
129
|
+
|
|
130
|
+
remaining_join_content = trimmed_join_text[keyword.length..].strip
|
|
131
|
+
|
|
132
|
+
return [keyword, remaining_join_content]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
[nil, nil]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def format_table_with_alias(table_text)
|
|
139
|
+
table_name = Util.first_word(table_text)
|
|
140
|
+
formatted_table_name = Util.upper_pascal_case(table_name)
|
|
141
|
+
table_alias = @table_registry.alias_for(table_name)
|
|
142
|
+
return formatted_table_name unless table_alias
|
|
143
|
+
|
|
144
|
+
"#{formatted_table_name} #{table_alias}"
|
|
10
145
|
end
|
|
11
146
|
end
|
|
12
147
|
end
|
|
@@ -4,17 +4,72 @@ module SqlBeautifier
|
|
|
4
4
|
module Clauses
|
|
5
5
|
class Select < Base
|
|
6
6
|
KEYWORD_PREFIX = "select "
|
|
7
|
-
|
|
7
|
+
CONTINUATION_INDENTATION = " "
|
|
8
|
+
DISTINCT_ON_PARENTHESIS_PATTERN = %r{distinct on\s*\(}
|
|
9
|
+
DISTINCT_ON_PATTERN = %r{distinct on }
|
|
10
|
+
LEADING_COMMA_PATTERN = %r{\A,\s*}
|
|
8
11
|
|
|
9
12
|
def call
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
prefix, remaining_columns = extract_prefix
|
|
14
|
+
columns = Tokenizer.split_by_top_level_commas(remaining_columns)
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
return format_with_prefix(prefix, columns) if prefix
|
|
17
|
+
return keyword_line(columns.first) if columns.length == 1
|
|
18
|
+
|
|
19
|
+
format_columns_list(columns)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def keyword_line(column)
|
|
25
|
+
"#{KEYWORD_PREFIX}#{column.strip}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def continuation_line(column)
|
|
29
|
+
"#{CONTINUATION_INDENTATION}#{column.strip}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def format_with_prefix(prefix, columns)
|
|
33
|
+
first_line = "#{KEYWORD_PREFIX}#{prefix}"
|
|
34
|
+
column_lines = columns.map { |column| continuation_line(column) }
|
|
35
|
+
|
|
36
|
+
"#{first_line}\n#{column_lines.join(",\n")}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_columns_list(columns)
|
|
40
|
+
column_lines = columns.map { |column| continuation_line(column) }
|
|
41
|
+
column_lines[0] = keyword_line(columns.first)
|
|
15
42
|
|
|
16
43
|
column_lines.join(",\n")
|
|
17
44
|
end
|
|
45
|
+
|
|
46
|
+
def extract_prefix
|
|
47
|
+
stripped_value = @value.strip
|
|
48
|
+
|
|
49
|
+
if stripped_value.start_with?("distinct on ")
|
|
50
|
+
extract_distinct_on_prefix(stripped_value)
|
|
51
|
+
elsif stripped_value.start_with?("distinct ")
|
|
52
|
+
remaining_columns = stripped_value.delete_prefix("distinct ").strip
|
|
53
|
+
|
|
54
|
+
["distinct", remaining_columns]
|
|
55
|
+
else
|
|
56
|
+
[nil, stripped_value]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def extract_distinct_on_prefix(stripped_value)
|
|
61
|
+
distinct_on_position = stripped_value.index(DISTINCT_ON_PARENTHESIS_PATTERN) || stripped_value.index(DISTINCT_ON_PATTERN)
|
|
62
|
+
opening_parenthesis_position = stripped_value.index(Constants::OPEN_PARENTHESIS, distinct_on_position)
|
|
63
|
+
return [nil, stripped_value] unless opening_parenthesis_position
|
|
64
|
+
|
|
65
|
+
closing_parenthesis_position = Tokenizer.find_matching_parenthesis(stripped_value, opening_parenthesis_position)
|
|
66
|
+
return [nil, stripped_value] unless closing_parenthesis_position
|
|
67
|
+
|
|
68
|
+
prefix = stripped_value[0..closing_parenthesis_position]
|
|
69
|
+
remaining_columns = stripped_value[(closing_parenthesis_position + 1)..].strip.sub(LEADING_COMMA_PATTERN, "")
|
|
70
|
+
|
|
71
|
+
[prefix, remaining_columns]
|
|
72
|
+
end
|
|
18
73
|
end
|
|
19
74
|
end
|
|
20
75
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlBeautifier
|
|
4
|
+
module ConditionFormatter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def format(text, indent_width:)
|
|
8
|
+
conditions = Tokenizer.split_top_level_conditions(text)
|
|
9
|
+
return text.strip if conditions.length <= 1 && !parse_condition_group(conditions.dig(0, 1))
|
|
10
|
+
|
|
11
|
+
conditions = flatten_same_conjunction_groups(conditions)
|
|
12
|
+
indentation = " " * indent_width
|
|
13
|
+
lines = []
|
|
14
|
+
|
|
15
|
+
conditions.each_with_index do |(conjunction, condition_text), index|
|
|
16
|
+
unwrapped_condition = unwrap_single_condition(condition_text)
|
|
17
|
+
formatted_condition_text = format_single_condition(unwrapped_condition, indent_width: indent_width)
|
|
18
|
+
|
|
19
|
+
line = begin
|
|
20
|
+
if index.zero?
|
|
21
|
+
"#{indentation}#{formatted_condition_text}"
|
|
22
|
+
else
|
|
23
|
+
"#{indentation}#{conjunction} #{formatted_condition_text}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
lines << line
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def flatten_same_conjunction_groups(conditions)
|
|
34
|
+
return conditions if conditions.length <= 1
|
|
35
|
+
|
|
36
|
+
outer_conjunction = conditions[1]&.first
|
|
37
|
+
return conditions unless outer_conjunction
|
|
38
|
+
return conditions unless conditions.drop(1).all? { |pair| pair[0] == outer_conjunction }
|
|
39
|
+
|
|
40
|
+
flattened_conditions = []
|
|
41
|
+
|
|
42
|
+
conditions.each do |conjunction, condition_text|
|
|
43
|
+
inner_conditions = parse_condition_group(condition_text)
|
|
44
|
+
|
|
45
|
+
if inner_conditions && flattenable_into_conjunction?(inner_conditions, outer_conjunction)
|
|
46
|
+
flatten_inner_conditions_into!(flattened_conditions, inner_conditions, conjunction, outer_conjunction)
|
|
47
|
+
else
|
|
48
|
+
flattened_conditions << [conjunction, condition_text]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
flattened_conditions
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rebuild_inline(inner_conditions)
|
|
56
|
+
parts = inner_conditions.map.with_index do |(conjunction, condition_text), index|
|
|
57
|
+
index.zero? ? condition_text : "#{conjunction} #{condition_text}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
"(#{parts.join(' ')})"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def unwrap_single_condition(condition)
|
|
64
|
+
output = condition.strip
|
|
65
|
+
|
|
66
|
+
while Tokenizer.outer_parentheses_wrap_all?(output)
|
|
67
|
+
inner_content = Util.strip_outer_parentheses(output)
|
|
68
|
+
inner_conditions = Tokenizer.split_top_level_conditions(inner_content)
|
|
69
|
+
break if inner_conditions.length > 1
|
|
70
|
+
|
|
71
|
+
output = inner_content
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
output
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_condition_group(condition_text)
|
|
78
|
+
return unless condition_text
|
|
79
|
+
|
|
80
|
+
trimmed_condition = condition_text.strip
|
|
81
|
+
return unless Tokenizer.outer_parentheses_wrap_all?(trimmed_condition)
|
|
82
|
+
|
|
83
|
+
inner_content = Util.strip_outer_parentheses(trimmed_condition)
|
|
84
|
+
inner_conditions = Tokenizer.split_top_level_conditions(inner_content)
|
|
85
|
+
return unless inner_conditions.length > 1
|
|
86
|
+
|
|
87
|
+
inner_conditions
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def format_single_condition(condition_text, indent_width:)
|
|
91
|
+
inner_conditions = parse_condition_group(condition_text)
|
|
92
|
+
return condition_text unless inner_conditions
|
|
93
|
+
|
|
94
|
+
inline_version = rebuild_inline(inner_conditions)
|
|
95
|
+
return inline_version if inline_version.length <= Constants::INLINE_GROUP_THRESHOLD
|
|
96
|
+
|
|
97
|
+
inner_content = Util.strip_outer_parentheses(condition_text.strip)
|
|
98
|
+
formatted_inner_content = format(inner_content, indent_width: indent_width + 4)
|
|
99
|
+
indentation = " " * indent_width
|
|
100
|
+
|
|
101
|
+
"(\n#{formatted_inner_content}\n#{indentation})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def flattenable_into_conjunction?(inner_conditions, outer_conjunction)
|
|
105
|
+
inner_conjunction = inner_conditions[1]&.first
|
|
106
|
+
|
|
107
|
+
inner_conjunction == outer_conjunction && inner_conditions.drop(1).all? { |pair| pair[0] == outer_conjunction }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def flatten_inner_conditions_into!(flattened_conditions, inner_conditions, conjunction, outer_conjunction)
|
|
111
|
+
inner_conditions.each_with_index do |inner_pair, inner_index|
|
|
112
|
+
condition_pair = begin
|
|
113
|
+
if flattened_conditions.empty?
|
|
114
|
+
[nil, inner_pair[1]]
|
|
115
|
+
elsif inner_index.zero?
|
|
116
|
+
[conjunction || outer_conjunction, inner_pair[1]]
|
|
117
|
+
else
|
|
118
|
+
[outer_conjunction, inner_pair[1]]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
flattened_conditions << condition_pair
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -11,5 +11,37 @@ module SqlBeautifier
|
|
|
11
11
|
"order by",
|
|
12
12
|
"limit",
|
|
13
13
|
].freeze
|
|
14
|
+
|
|
15
|
+
JOIN_KEYWORDS = [
|
|
16
|
+
"inner join",
|
|
17
|
+
"left outer join",
|
|
18
|
+
"right outer join",
|
|
19
|
+
"full outer join",
|
|
20
|
+
"left join",
|
|
21
|
+
"right join",
|
|
22
|
+
"full join",
|
|
23
|
+
"cross join",
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
JOIN_KEYWORDS_BY_LENGTH = JOIN_KEYWORDS.sort_by { |keyword| -keyword.length }.freeze
|
|
27
|
+
JOIN_KEYWORD_PATTERN = %r{\b(#{JOIN_KEYWORDS.map { |keyword| Regexp.escape(keyword) }.join('|')})\b}i
|
|
28
|
+
|
|
29
|
+
CONJUNCTIONS = %w[and or].freeze
|
|
30
|
+
BETWEEN_KEYWORD = "between"
|
|
31
|
+
|
|
32
|
+
INLINE_GROUP_THRESHOLD = 100
|
|
33
|
+
KEYWORD_COLUMN_WIDTH = 8
|
|
34
|
+
|
|
35
|
+
LEADING_KEYWORD_INDENT_PATTERN = %r{\A#{' ' * KEYWORD_COLUMN_WIDTH}}
|
|
36
|
+
|
|
37
|
+
OPEN_PARENTHESIS = "("
|
|
38
|
+
CLOSE_PARENTHESIS = ")"
|
|
39
|
+
COMMA = ","
|
|
40
|
+
|
|
41
|
+
WHITESPACE_REGEX = %r{\s+}
|
|
42
|
+
WHITESPACE_CHARACTER_REGEX = %r{\s}
|
|
43
|
+
SINGLE_QUOTE = "'"
|
|
44
|
+
DOUBLE_QUOTE = '"'
|
|
45
|
+
ESCAPED_DOUBLE_QUOTE = '""'
|
|
14
46
|
end
|
|
15
47
|
end
|