Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 0.7.5 / Unreleased

* [Added]
* [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

* [Deprecated]

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ SqlQuery.new('player/get_by_email', email: 'e@mail.dev')
SqlQuery.configure do |config|
config.path = '/app/sql_templates'
config.adapter = ActiveRecord::Base
config.remove_comments = :all # :none, :oneline, :multiline, :all (default: :all)
config.remove_comments_from = :all # :none, :prepared_for_logs, :all (default: :all)
end
```

Expand All @@ -107,6 +109,19 @@ end

* adapter - class which implements connection method.

* remove_comments - Controls which types of SQL comments to remove:
* `:none` - Don't remove any comments
* `:oneline` - Remove only single-line comments (`--`)
* `:multiline` - Remove only multi-line comments (`/* */`)
* `:all` - Remove both types (default)

* remove_comments_from - Controls where to apply comment removal:
* `:none` - Don't remove comments anywhere
* `:prepared_for_logs` - Remove comments only in `prepared_for_logs` method
* `:all` - Remove comments from all queries (default)

**Note:** Comments within quoted strings (single quotes, double quotes, or PostgreSQL dollar quotes) are always preserved regardless of settings.

### Partials

You can prepare part of sql query in partial file and reuse it in multiple queries.
Expand Down
19 changes: 8 additions & 11 deletions lib/sql_query.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require 'erb'
require_relative 'sql_query/config'
require_relative 'sql_query/comment_remover'

class SqlQuery
attr_reader :connection
Expand Down Expand Up @@ -34,7 +36,7 @@ def exec_query(prepare = true)
end

def sql
@sql ||= prepare_query(false)
@sql ||= apply_comment_removal(prepare_query(false), for_logs: false)
end

def pretty_sql
Expand All @@ -46,7 +48,7 @@ def quote(value)
end

def prepared_for_logs
@prepared_for_logs ||= prepare_query(true)
@prepared_for_logs ||= apply_comment_removal(prepare_query(true), for_logs: true)
end

def partial(partial_name, partial_options = {})
Expand All @@ -65,15 +67,6 @@ def self.configure
yield(config)
end

class Config
attr_accessor :path, :adapter

def initialize
@path = '/app/sql_queries'
@adapter = ActiveRecord::Base
end
end

private

def prepare_query(for_logs)
Expand All @@ -91,6 +84,10 @@ def split_to_path_and_name(file)
end
end

def apply_comment_removal(sql, for_logs:)
CommentRemover.new(self.class.config).remove(sql, for_logs: for_logs)
end

def pretty(value)
# override inspect to be more human readable from console
# code copy from ActiveRecord
Expand Down
214 changes: 214 additions & 0 deletions lib/sql_query/comment_remover.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# frozen_string_literal: true

class SqlQuery
# Service class responsible for removing SQL comments from queries
# while preserving content within quoted strings.
#
# Supports SQL-92 standard comment syntax:
# - Single-line comments: -- comment
# - Multi-line comments: /* comment */
#
# Preserves content in:
# - Single-quoted strings: '...'
# - Double-quoted identifiers: "..."
# - Dollar-quoted strings (PostgreSQL): $$...$$ or $tag$...$tag$
#
# @example
# config = SqlQuery.config
# remover = CommentRemover.new(config)
# sql = "SELECT * FROM t -- comment\nWHERE id = 1"
# remover.remove(sql, for_logs: true)
# # => "SELECT * FROM t \nWHERE id = 1"
#
# rubocop:disable Metrics/ClassLength
class CommentRemover
def initialize(config)
@config = config
end

# Removes comments from SQL based on the configuration
#
# @param sql [String] the SQL string to process
# @param for_logs [Boolean] whether the query is being prepared for logs
# @return [String] SQL with comments removed (or unchanged based on config)
def remove(sql, for_logs:)
return sql unless @config.should_comments_be_removed?(for_logs: for_logs)
return sql if @config.remove_comments == :none

state = init_state
result = []
i = 0

i = process_character(sql, i, result, state) while i < sql.length

result.join
end

private

def init_state
{
in_single: false, # Inside '...'
in_double: false, # Inside "..."
in_dollar: false, # Inside $$...$$ or $tag$...$tag$
dollar_tag: nil, # Current dollar quote tag
in_line_comment: false, # Inside -- comment
in_block_comment: false, # Inside /* */ comment
escape_next: false # Next char is escaped (for backslash escapes)
}
end

# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize
def process_character(sql, index, result, state)
char = sql[index]
next_char = index + 1 < sql.length ? sql[index + 1] : nil

# Handle escape sequences
if state[:escape_next]
result << char unless in_comment?(state)
state[:escape_next] = false
return index + 1
end

# Check for backslash escape
if char == '\\' && (state[:in_single] || state[:in_double])
result << char unless in_comment?(state)
state[:escape_next] = true
return index + 1
end

# If in comment, check for comment end
if state[:in_line_comment]
if char == "\n"
state[:in_line_comment] = false
result << char # Preserve newline
end
# Skip comment content
return index + 1
end

if state[:in_block_comment]
if char == '*' && next_char == '/'
state[:in_block_comment] = false
return index + 2 # Skip */
end
# Skip comment content
return index + 1
end

# Not in comment - check for comment start (only if not in quotes)
unless in_quote?(state)
if char == '-' && next_char == '-' && should_remove_oneline?
state[:in_line_comment] = true
return index + 2 # Skip --
end

if char == '/' && next_char == '*' && should_remove_multiline?
state[:in_block_comment] = true
return index + 2 # Skip /*
end
end

# Handle quotes
if char == "'" && !state[:in_double] && !state[:in_dollar]
if state[:in_single] && next_char == "'"
# Escaped single quote (SQL style: '')
result << char << next_char
return index + 2
else
state[:in_single] = !state[:in_single]
result << char
return index + 1
end
end

if char == '"' && !state[:in_single] && !state[:in_dollar]
if state[:in_double] && next_char == '"'
# Escaped double quote
result << char << next_char
return index + 2
else
state[:in_double] = !state[:in_double]
result << char
return index + 1
end
end

# Handle dollar quotes (PostgreSQL)
if char == '$' && !state[:in_single] && !state[:in_double]
tag, tag_length = extract_dollar_tag(sql, index)
if tag
if state[:in_dollar] && tag == state[:dollar_tag]
# Closing dollar quote
state[:in_dollar] = false
state[:dollar_tag] = nil
tag_length.times { |i| result << sql[index + i] }
return index + tag_length
elsif !state[:in_dollar]
# Opening dollar quote
state[:in_dollar] = true
state[:dollar_tag] = tag
tag_length.times { |i| result << sql[index + i] }
return index + tag_length
end
end
end

# Regular character
result << char
index + 1
end
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
# rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize

def in_quote?(state)
state[:in_single] || state[:in_double] || state[:in_dollar]
end

def in_comment?(state)
state[:in_line_comment] || state[:in_block_comment]
end

def should_remove_oneline?
%i[oneline all].include?(@config.remove_comments)
end

def should_remove_multiline?
%i[multiline all].include?(@config.remove_comments)
end

# Extracts dollar quote tag from position
# Returns [tag, length] or [nil, nil]
# Matches: $$ or $tag$ where tag is alphanumeric/underscore
# rubocop:disable Metrics/MethodLength
def extract_dollar_tag(sql, index)
return [nil, nil] unless sql[index] == '$'

# Look for closing $
i = index + 1
tag_chars = []

while i < sql.length
char = sql[i]
if char == '$'
# Found closing $
tag = tag_chars.empty? ? '' : tag_chars.join
length = i - index + 1
return [tag, length]
elsif char =~ /[a-zA-Z0-9_]/
tag_chars << char
i += 1
else
# Invalid character for dollar quote tag
return [nil, nil]
end
end

# No closing $ found
[nil, nil]
end
# rubocop:enable Metrics/MethodLength
end
# rubocop:enable Metrics/ClassLength
end
38 changes: 38 additions & 0 deletions lib/sql_query/config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

class SqlQuery
# Configuration class for SqlQuery behavior
#
# @example
# SqlQuery.configure do |config|
# config.path = '/app/sql_queries'
# config.adapter = ActiveRecord::Base
# config.remove_comments = :all
# config.remove_comments_from = :prepared_for_logs
# end
class Config
attr_accessor :path, :adapter, :remove_comments, :remove_comments_from

def initialize
@path = '/app/sql_queries'
@adapter = ActiveRecord::Base
@remove_comments = :all # :none, :oneline, :multiline, :all
@remove_comments_from = :all # :none, :prepared_for_logs, :all
end

# Determines if comments should be removed for a given context
#
# @param for_logs [Boolean] whether the query is being prepared for logs
# @return [Boolean] true if comments should be removed
def should_comments_be_removed?(for_logs:)
case remove_comments_from
when :prepared_for_logs
for_logs
when :all
true
else
false
end
end
end
end
Loading