diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b3a55..4d5f63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # SqlQuery change log -## 0.7.5 / Unreleased +## 1.0.0 / Unreleased * [Added] Configurable SQL comment removal feature with `remove_comments` and `remove_comments_from` configuration options. Supports selective removal of single-line (`--`) and multi-line (`/* */`) comments while preserving comments within quoted strings (single, double, and PostgreSQL dollar quotes). Addresses https://github.com/sufleR/sql_query/issues/20 @@ -8,7 +8,7 @@ * [Removed] -* [Fixed] +* [Fixed] Whitespace normalization now properly supports multiline ERB blocks. Previously, `prepared_for_logs` would corrupt SQL templates containing multiline ERB code blocks (e.g., `<% ... %>`) by normalizing whitespace before ERB processing. The new `WhitespaceNormalizer` class renders ERB first, then intelligently collapses whitespace while preserving content within SQL quoted strings (single quotes, double quotes, and escaped quotes). ## 0.7.4 / 2024-04-20 diff --git a/lib/sql_query.rb b/lib/sql_query.rb index 11dfa20..5d7c16c 100644 --- a/lib/sql_query.rb +++ b/lib/sql_query.rb @@ -3,6 +3,7 @@ require 'erb' require_relative 'sql_query/config' require_relative 'sql_query/comment_remover' +require_relative 'sql_query/whitespace_normalizer' class SqlQuery attr_reader :connection @@ -71,8 +72,12 @@ def self.configure def prepare_query(for_logs) query_template = File.read(file_path) - query_template = query_template.gsub(/(\n|\s)+/, ' ') if for_logs - ERB.new(query_template).result(binding) + rendered_sql = ERB.new(query_template).result(binding) + + return rendered_sql unless for_logs + + # Normalize whitespace while preserving quoted strings + WhitespaceNormalizer.new.normalize(rendered_sql) end def split_to_path_and_name(file) diff --git a/lib/sql_query/whitespace_normalizer.rb b/lib/sql_query/whitespace_normalizer.rb new file mode 100644 index 0000000..7c8028a --- /dev/null +++ b/lib/sql_query/whitespace_normalizer.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class SqlQuery + # Service class responsible for normalizing whitespace in SQL queries + # while preserving content within quoted strings. + # + # This class collapses multiple whitespace characters (spaces, tabs, newlines) + # into single spaces, except when they appear within SQL string literals. + # + # @example + # normalizer = WhitespaceNormalizer.new + # sql = "SELECT *\n FROM users\n WHERE name = ' John '" + # normalizer.normalize(sql) + # # => "SELECT * FROM users WHERE name = ' John '" + class WhitespaceNormalizer + # Normalizes whitespace in the given SQL string + # + # @param sql [String] the SQL string to normalize + # @return [String] the normalized SQL string + def normalize(sql) + state = { in_single: false, in_double: false, prev_space: false } + result = [] + i = 0 + + i = process_character(sql, i, result, state) while i < sql.length + + result.join + end + + private + + # rubocop:disable Metrics/MethodLength + def process_character(sql, index, result, state) + char = sql[index] + + if single_quote?(char, state) + process_single_quote(sql, index, result, state) + elsif double_quote?(char, state) + process_double_quote(sql, index, result, state) + elsif whitespace?(char) + process_whitespace(char, result, state) + index + 1 + else + process_regular_char(char, result, state) + index + 1 + end + end + + def single_quote?(char, state) + char == "'" && !state[:in_double] + end + + def double_quote?(char, state) + char == '"' && !state[:in_single] + end + + def whitespace?(char) + char =~ /\s/ + end + # rubocop:enable Metrics/MethodLength + + # rubocop:disable Metrics/AbcSize + def process_single_quote(sql, index, result, state) + if state[:in_single] && index + 1 < sql.length && sql[index + 1] == "'" + # Doubled quote (escape) - add both + result << sql[index] << sql[index + 1] + state[:prev_space] = false + index + 2 + else + # Normal quote - toggle state + state[:in_single] = !state[:in_single] + result << sql[index] + state[:prev_space] = false + index + 1 + end + end + + def process_double_quote(sql, index, result, state) + if state[:in_double] && index + 1 < sql.length && sql[index + 1] == '"' + # Doubled quote (escape) - add both + result << sql[index] << sql[index + 1] + state[:prev_space] = false + index + 2 + else + # Normal quote - toggle state + state[:in_double] = !state[:in_double] + result << sql[index] + state[:prev_space] = false + index + 1 + end + end + + def process_whitespace(char, result, state) + if state[:in_single] || state[:in_double] + # Inside quotes: preserve whitespace + result << char + state[:prev_space] = false + elsif !state[:prev_space] + # Outside quotes: collapse to single space + result << ' ' + state[:prev_space] = true + end + end + + def process_regular_char(char, result, state) + result << char + state[:prev_space] = false + end + # rubocop:enable Metrics/AbcSize + end +end diff --git a/spec/sql_queries/multiline_erb.sql.erb b/spec/sql_queries/multiline_erb.sql.erb new file mode 100644 index 0000000..8ba41f7 --- /dev/null +++ b/spec/sql_queries/multiline_erb.sql.erb @@ -0,0 +1,9 @@ +<% + field1 = @field1 || 'default1' + field2 = @field2 || 'default2' +%> +SELECT + <%= quote field1 %> as field1, + <%= quote field2 %> as field2 +FROM players +WHERE email = <%= quote @email %> diff --git a/spec/sql_query_spec.rb b/spec/sql_query_spec.rb index 58fde99..52dd66b 100644 --- a/spec/sql_query_spec.rb +++ b/spec/sql_query_spec.rb @@ -211,6 +211,23 @@ class Model < ActiveRecord::Base .to eq("SELECT * FROM players WHERE email = ' e@mail.dev ' ") end end + + context 'when template has multiline ERB blocks' do + let(:file_name) { :multiline_erb } + let(:options) { { email: 'test@dev.com', field1: 'val1', field2: 'val2' } } + let(:query) { described_class.new(file_name, options) } + + it 'processes multiline ERB correctly without syntax errors' do + expect { query.prepared_for_logs }.not_to raise_error + end + + it 'collapses SQL whitespace while preserving ERB processing' do + result = query.prepared_for_logs + expect(result).to include("'val1' as field1") + expect(result).to include("'val2' as field2") + expect(result).not_to include("\n") # All newlines should be collapsed + end + end end describe 'comment removal integration' do