sql_beautifier 0.1.4 → 0.3.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.
@@ -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 = Util.whitespace(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 <= SqlBeautifier.config_for(: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 = Util.whitespace(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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ class Configuration
5
+ DEFAULTS = {
6
+ keyword_case: :lower,
7
+ keyword_column_width: 8,
8
+ indent_spaces: 4,
9
+ clause_spacing_mode: :compact,
10
+ table_name_format: :pascal_case,
11
+ inline_group_threshold: 100,
12
+ alias_strategy: :initials,
13
+ }.freeze
14
+
15
+ attr_accessor :keyword_case
16
+ attr_accessor :keyword_column_width
17
+ attr_accessor :indent_spaces
18
+ attr_accessor :clause_spacing_mode
19
+ attr_accessor :table_name_format
20
+ attr_accessor :inline_group_threshold
21
+ attr_accessor :alias_strategy
22
+
23
+ def initialize
24
+ reset!
25
+ end
26
+
27
+ def reset!
28
+ DEFAULTS.each do |key, value|
29
+ public_send(:"#{key}=", value)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -11,5 +11,32 @@ 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
+ OPEN_PARENTHESIS = "("
33
+ CLOSE_PARENTHESIS = ")"
34
+ COMMA = ","
35
+
36
+ WHITESPACE_REGEX = %r{\s+}
37
+ WHITESPACE_CHARACTER_REGEX = %r{\s}
38
+ SINGLE_QUOTE = "'"
39
+ DOUBLE_QUOTE = '"'
40
+ ESCAPED_DOUBLE_QUOTE = '""'
14
41
  end
15
42
  end
@@ -6,8 +6,9 @@ module SqlBeautifier
6
6
  new(value).call
7
7
  end
8
8
 
9
- def initialize(value)
9
+ def initialize(value, depth: 0)
10
10
  @value = value
11
+ @depth = depth
11
12
  end
12
13
 
13
14
  def call
@@ -20,19 +21,22 @@ module SqlBeautifier
20
21
  return "#{@normalized_value}\n" if first_clause_position.nil? || first_clause_position.positive?
21
22
 
22
23
  @clauses = Tokenizer.split_into_clauses(@normalized_value)
24
+ @table_registry = TableRegistry.new(@clauses[:from]) if @clauses[:from].present?
23
25
  @parts = []
24
26
 
25
27
  append_clause!(:select, Clauses::Select)
26
- append_clause!(:from, Clauses::From)
28
+ append_from_clause!
27
29
  append_clause!(:where, Clauses::Where)
28
30
  append_clause!(:group_by, Clauses::GroupBy)
29
31
  append_clause!(:having, Clauses::Having)
30
32
  append_clause!(:order_by, Clauses::OrderBy)
31
33
  append_clause!(:limit, Clauses::Limit)
32
34
 
33
- output = @parts.join("\n\n")
35
+ output = @parts.join(clause_separator)
34
36
  return "#{@normalized_value}\n" if output.empty?
35
37
 
38
+ output = SubqueryFormatter.format(output, @depth)
39
+ output = @table_registry.apply_aliases(output) if @table_registry
36
40
  "#{output}\n"
37
41
  end
38
42
 
@@ -44,5 +48,55 @@ module SqlBeautifier
44
48
 
45
49
  @parts << formatter_class.call(value)
46
50
  end
51
+
52
+ def append_from_clause!
53
+ value = @clauses[:from]
54
+ return unless value.present?
55
+
56
+ @parts << Clauses::From.call(value, table_registry: @table_registry)
57
+ end
58
+
59
+ def clause_separator
60
+ return "\n\n" if SqlBeautifier.config_for(:clause_spacing_mode) == :spacious
61
+ return "\n\n" unless compact_query?
62
+
63
+ "\n"
64
+ end
65
+
66
+ def compact_query?
67
+ compact_clause_set? && single_select_column? && single_from_table? && one_or_fewer_conditions?
68
+ end
69
+
70
+ def compact_clause_set?
71
+ clause_keys = @clauses.keys
72
+ allowed_keys = %i[select from where order_by limit]
73
+
74
+ clause_keys.all? { |key| allowed_keys.include?(key) } && clause_keys.include?(:select) && clause_keys.include?(:from)
75
+ end
76
+
77
+ def single_select_column?
78
+ select_value = @clauses[:select]
79
+ return false unless select_value.present?
80
+
81
+ formatted_select = Clauses::Select.call(select_value)
82
+ formatted_select.lines.length == 1
83
+ end
84
+
85
+ def single_from_table?
86
+ from_value = @clauses[:from]
87
+ return false unless from_value.present?
88
+
89
+ join_keywords = Constants::JOIN_KEYWORDS_BY_LENGTH.any? { |keyword| Tokenizer.find_top_level_keyword(from_value, keyword) }
90
+ return false if join_keywords
91
+
92
+ !from_value.match?(%r{,})
93
+ end
94
+
95
+ def one_or_fewer_conditions?
96
+ where_value = @clauses[:where]
97
+ return true unless where_value.present?
98
+
99
+ Tokenizer.split_top_level_conditions(where_value).length <= 1
100
+ end
47
101
  end
48
102
  end
@@ -18,18 +18,23 @@ module SqlBeautifier
18
18
  @source = @value.strip
19
19
  return unless @source.present?
20
20
 
21
+ @source = strip_comments(@source)
22
+ @source = strip_trailing_semicolons(@source)
23
+ @source = @source.strip
24
+ return unless @source.present?
25
+
21
26
  @output = +""
22
27
  @position = 0
23
28
 
24
29
  while @position < @source.length
25
30
  case current_character
26
- when "'"
31
+ when Constants::SINGLE_QUOTE
27
32
  consume_string_literal!
28
33
 
29
- when '"'
34
+ when Constants::DOUBLE_QUOTE
30
35
  consume_quoted_identifier!
31
36
 
32
- when %r{\s}
37
+ when Constants::WHITESPACE_CHARACTER_REGEX
33
38
  collapse_whitespace!
34
39
 
35
40
  else
@@ -50,7 +55,7 @@ module SqlBeautifier
50
55
  def collapse_whitespace!
51
56
  @output << " "
52
57
  @position += 1
53
- @position += 1 while @position < @source.length && @source[@position] =~ %r{\s}
58
+ @position += 1 while @position < @source.length && @source[@position] =~ Constants::WHITESPACE_CHARACTER_REGEX
54
59
  end
55
60
 
56
61
  def consume_string_literal!
@@ -61,10 +66,10 @@ module SqlBeautifier
61
66
  character = current_character
62
67
  @output << character
63
68
 
64
- if character == "'" && @source[@position + 1] == "'"
69
+ if character == Constants::SINGLE_QUOTE && @source[@position + 1] == Constants::SINGLE_QUOTE
65
70
  @position += 1
66
71
  @output << current_character
67
- elsif character == "'"
72
+ elsif character == Constants::SINGLE_QUOTE
68
73
  @position += 1
69
74
  return
70
75
  end
@@ -81,10 +86,10 @@ module SqlBeautifier
81
86
  while @position < @source.length
82
87
  character = current_character
83
88
 
84
- if character == '"' && @source[@position + 1] == '"'
85
- identifier << '"'
89
+ if character == Constants::DOUBLE_QUOTE && @source[@position + 1] == Constants::DOUBLE_QUOTE
90
+ identifier << Constants::DOUBLE_QUOTE
86
91
  @position += 2
87
- elsif character == '"'
92
+ elsif character == Constants::DOUBLE_QUOTE
88
93
  @position += 1
89
94
  @output << format_identifier(identifier)
90
95
  return
@@ -100,17 +105,81 @@ module SqlBeautifier
100
105
  end
101
106
 
102
107
  def format_identifier(identifier)
103
- lowercased = identifier.downcase
108
+ downcased_identifier = identifier.downcase
109
+ return downcased_identifier unless requires_quoting?(downcased_identifier)
104
110
 
105
- if requires_quoting?(lowercased)
106
- "\"#{lowercased.gsub('"', '""')}\""
107
- else
108
- lowercased
109
- end
111
+ escaped_identifier = Util.escape_double_quote(downcased_identifier)
112
+ Util.double_quote_string(escaped_identifier)
110
113
  end
111
114
 
112
115
  def requires_quoting?(identifier)
113
116
  identifier !~ SAFE_UNQUOTED_IDENTIFIER
114
117
  end
118
+
119
+ def strip_trailing_semicolons(sql)
120
+ sql.sub(%r{;[[:space:]]*\z}, "")
121
+ end
122
+
123
+ def strip_comments(sql)
124
+ output = +""
125
+ position = 0
126
+ in_single_quoted_string = false
127
+ in_double_quoted_identifier = false
128
+
129
+ while position < sql.length
130
+ character = sql[position]
131
+
132
+ if in_single_quoted_string
133
+ output << character
134
+
135
+ if character == Constants::SINGLE_QUOTE && sql[position + 1] == Constants::SINGLE_QUOTE
136
+ position += 1
137
+ output << sql[position]
138
+ elsif character == Constants::SINGLE_QUOTE
139
+ in_single_quoted_string = false
140
+ end
141
+
142
+ position += 1
143
+ next
144
+ end
145
+
146
+ if in_double_quoted_identifier
147
+ output << character
148
+
149
+ if character == Constants::DOUBLE_QUOTE && sql[position + 1] == Constants::DOUBLE_QUOTE
150
+ position += 1
151
+ output << sql[position]
152
+ elsif character == Constants::DOUBLE_QUOTE
153
+ in_double_quoted_identifier = false
154
+ end
155
+
156
+ position += 1
157
+ next
158
+ end
159
+
160
+ if character == Constants::SINGLE_QUOTE
161
+ in_single_quoted_string = true
162
+ output << character
163
+ position += 1
164
+ elsif character == Constants::DOUBLE_QUOTE
165
+ in_double_quoted_identifier = true
166
+ output << character
167
+ position += 1
168
+ elsif character == "-" && sql[position + 1] == "-"
169
+ position += 2
170
+ position += 1 while position < sql.length && sql[position] != "\n"
171
+ elsif character == "/" && sql[position + 1] == "*"
172
+ output << " " unless output.empty? || output[-1] =~ Constants::WHITESPACE_CHARACTER_REGEX
173
+ position += 2
174
+ position += 1 while position < sql.length && !(sql[position] == "*" && sql[position + 1] == "/")
175
+ position += 2 if position < sql.length
176
+ else
177
+ output << character
178
+ position += 1
179
+ end
180
+ end
181
+
182
+ output
183
+ end
115
184
  end
116
185
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ module SubqueryFormatter
5
+ module_function
6
+
7
+ def format(text, base_indent)
8
+ output = +""
9
+ position = 0
10
+
11
+ while position < text.length
12
+ subquery_position = find_top_level_subquery(text, position)
13
+
14
+ unless subquery_position
15
+ output << text[position..]
16
+ break
17
+ end
18
+
19
+ output << text[position...subquery_position]
20
+
21
+ closing_position = Tokenizer.find_matching_parenthesis(text, subquery_position)
22
+
23
+ unless closing_position
24
+ output << text[subquery_position..]
25
+ break
26
+ end
27
+
28
+ inner_sql = text[(subquery_position + 1)...closing_position].strip
29
+ subquery_base_indent = subquery_base_indent_for(text, subquery_position, base_indent)
30
+ output << format_subquery(inner_sql, subquery_base_indent)
31
+ position = closing_position + 1
32
+ end
33
+
34
+ output
35
+ end
36
+
37
+ def find_top_level_subquery(text, start_position)
38
+ position = start_position
39
+ in_single_quoted_string = false
40
+ in_double_quoted_identifier = false
41
+ while position < text.length
42
+ character = text[position]
43
+
44
+ if in_single_quoted_string
45
+ if character == Constants::SINGLE_QUOTE && text[position + 1] == Constants::SINGLE_QUOTE
46
+ position += 2
47
+ elsif character == Constants::SINGLE_QUOTE
48
+ in_single_quoted_string = false
49
+ position += 1
50
+ else
51
+ position += 1
52
+ end
53
+ next
54
+ end
55
+
56
+ if in_double_quoted_identifier
57
+ if character == Constants::DOUBLE_QUOTE && text[position + 1] == Constants::DOUBLE_QUOTE
58
+ position += 2
59
+ elsif character == Constants::DOUBLE_QUOTE
60
+ in_double_quoted_identifier = false
61
+ position += 1
62
+ else
63
+ position += 1
64
+ end
65
+ next
66
+ end
67
+
68
+ case character
69
+ when Constants::SINGLE_QUOTE
70
+ in_single_quoted_string = true
71
+ when Constants::DOUBLE_QUOTE
72
+ in_double_quoted_identifier = true
73
+ when Constants::OPEN_PARENTHESIS
74
+ return position if select_follows?(text, position)
75
+ end
76
+
77
+ position += 1
78
+ end
79
+
80
+ nil
81
+ end
82
+
83
+ def format_subquery(inner_sql, base_indent)
84
+ indent_spaces = SqlBeautifier.config_for(:indent_spaces) || 4
85
+ subquery_indent = base_indent + indent_spaces
86
+ formatted = Formatter.new(inner_sql, depth: subquery_indent).call
87
+ return "(#{inner_sql})" unless formatted
88
+
89
+ indentation = Util.whitespace(subquery_indent)
90
+ indented_lines = formatted.chomp.lines.map { |line| line.strip.empty? ? "\n" : "#{indentation}#{line}" }.join
91
+
92
+ "(\n#{indented_lines}\n#{Util.whitespace(base_indent)})"
93
+ end
94
+
95
+ def subquery_base_indent_for(text, subquery_position, default_base_indent)
96
+ line_start_position = text.rindex("\n", subquery_position - 1)
97
+ line_start_position = line_start_position ? line_start_position + 1 : 0
98
+ line_before_subquery = text[line_start_position...subquery_position]
99
+ line_leading_spaces = line_before_subquery[%r{\A[[:space:]]*}].to_s.length
100
+
101
+ return default_base_indent unless line_before_subquery.lstrip.match?(%r{\Awhere(?:[[:space:]]|$)}i)
102
+
103
+ default_base_indent + line_leading_spaces + SqlBeautifier.config_for(:keyword_column_width)
104
+ end
105
+
106
+ def select_follows?(text, position)
107
+ remaining_text = text[(position + 1)..]
108
+ return false unless remaining_text
109
+
110
+ remaining_text.match?(%r{\A[[:space:]]*select(?:[[:space:]]|\()}i)
111
+ end
112
+ end
113
+ end