pg_search 2.3.8 → 2.4.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -6
  3. data/.github/workflows/codeql.yml +0 -1
  4. data/.gitignore +0 -1
  5. data/.standard.yml +1 -1
  6. data/CHANGELOG.md +70 -65
  7. data/CONTRIBUTING.md +1 -1
  8. data/LICENSE +1 -1
  9. data/README.md +2 -2
  10. data/doc/plans/2026-09-05-arel-stack.md +43 -0
  11. data/doc/plans/2026-09-06-arel-remaining-expressions.md +48 -0
  12. data/lib/pg_search/configuration/association.rb +37 -23
  13. data/lib/pg_search/configuration/column.rb +17 -12
  14. data/lib/pg_search/configuration/foreign_column.rb +3 -5
  15. data/lib/pg_search/features/dmetaphone.rb +3 -7
  16. data/lib/pg_search/features/feature.rb +8 -4
  17. data/lib/pg_search/features/trigram.rb +6 -20
  18. data/lib/pg_search/features/tsearch.rb +30 -36
  19. data/lib/pg_search/features.rb +2 -0
  20. data/lib/pg_search/migration/generator.rb +1 -5
  21. data/lib/pg_search/multisearch/rebuilder.rb +69 -65
  22. data/lib/pg_search/normalizer.rb +37 -18
  23. data/lib/pg_search/scope_options.rb +28 -52
  24. data/lib/pg_search/version.rb +1 -1
  25. data/pg_search.gemspec +4 -4
  26. data/spec/integration/associations_spec.rb +121 -0
  27. data/spec/integration/pg_search_spec.rb +48 -0
  28. data/spec/lib/pg_search/configuration/association_spec.rb +32 -58
  29. data/spec/lib/pg_search/configuration/column_spec.rb +73 -15
  30. data/spec/lib/pg_search/configuration/foreign_column_spec.rb +52 -20
  31. data/spec/lib/pg_search/features/dmetaphone_spec.rb +2 -2
  32. data/spec/lib/pg_search/features/trigram_spec.rb +3 -3
  33. data/spec/lib/pg_search/features/tsearch_spec.rb +8 -8
  34. data/spec/lib/pg_search/multisearch/rebuilder_spec.rb +219 -229
  35. data/spec/lib/pg_search/multisearch_spec.rb +16 -16
  36. data/spec/lib/pg_search/normalizer_spec.rb +64 -11
  37. data/spec/lib/pg_search_spec.rb +2 -21
  38. metadata +8 -8
@@ -19,8 +19,21 @@ module PgSearch
19
19
  @model.reflect_on_association(@name).table_name
20
20
  end
21
21
 
22
- def join(primary_key)
23
- "LEFT OUTER JOIN (#{relation(primary_key).to_sql}) #{subselect_alias} ON #{subselect_alias}.id = #{primary_key}"
22
+ def arel_table = @model.reflect_on_association(@name).klass.arel_table
23
+
24
+ # Builds an outer join retaining association-scope binds.
25
+ #
26
+ # Arguments
27
+ #
28
+ # +primary_key+:: An Arel expression or trusted SQL String identifying the
29
+ # model's primary-key column, not a record's key value.
30
+ #
31
+ # Returns a composable outer join to the search subquery.
32
+ def to_arel(primary_key)
33
+ primary_key = Arel.sql(primary_key) if primary_key.is_a?(String)
34
+ subquery = relation(primary_key).arel.as(subselect_alias)
35
+ on_condition = Arel::Nodes::On.new(subquery[:id].eq(primary_key))
36
+ Arel::Nodes::OuterJoin.new(subquery, on_condition)
24
37
  end
25
38
 
26
39
  def subselect_alias
@@ -29,32 +42,33 @@ module PgSearch
29
42
 
30
43
  private
31
44
 
32
- def selects
33
- if singular_association?
34
- selects_for_singular_association
35
- else
36
- selects_for_multiple_association
37
- end
38
- end
39
-
40
- def selects_for_singular_association
41
- columns.map do |column|
42
- "#{column.full_name}::text AS #{column.alias}"
43
- end.join(", ")
44
- end
45
-
46
- def selects_for_multiple_association
47
- columns.map do |column|
48
- "string_agg(#{column.full_name}::text, ' ') AS #{column.alias}"
49
- end.join(", ")
50
- end
51
-
52
45
  def relation(primary_key)
53
- result = @model.unscoped.joins(@name).select("#{primary_key} AS id, #{selects}")
46
+ result = @model.unscoped.joins(@name).select(
47
+ primary_key.as("id"),
48
+ *selects
49
+ )
54
50
  result = result.group(primary_key) unless singular_association?
55
51
  result
56
52
  end
57
53
 
54
+ def selects
55
+ columns.map do |column|
56
+ cast_node = Arel::Nodes::NamedFunction.new(
57
+ "cast",
58
+ [column.source_attribute.as(Arel.sql("text"))]
59
+ )
60
+ projection = if singular_association?
61
+ cast_node
62
+ else
63
+ Arel::Nodes::NamedFunction.new(
64
+ "string_agg",
65
+ [cast_node, Arel::Nodes.build_quoted(" ")]
66
+ )
67
+ end
68
+ projection.as(column.alias)
69
+ end
70
+ end
71
+
58
72
  def singular_association?
59
73
  %i[has_one belongs_to].include?(@model.reflect_on_association(@name).macro)
60
74
  end
@@ -12,32 +12,37 @@ module PgSearch
12
12
  @column_name = column_name
13
13
  @weight = weight
14
14
  @model = model
15
- @connection = model.connection
16
15
  end
17
16
 
18
- def full_name
17
+ # Physical source-column attribute, or the supplied trusted SQL literal.
18
+ # Foreign columns refer to the association table, not the search alias.
19
+ def source_attribute
19
20
  return @column_name if @column_name.is_a?(Arel::Nodes::SqlLiteral)
20
21
 
21
- "#{table_name}.#{column_name}"
22
+ Arel::Attributes::Attribute.new(source_table, @name)
22
23
  end
23
24
 
24
- def to_sql
25
- "coalesce((#{expression})::text, '')"
26
- end
25
+ # Composable search expression cast to text, with NULL mapped to "".
26
+ # Foreign columns use their derived search alias rather than the source.
27
+ def to_arel = coalesce_to_blank_string(cast_to_text(attribute))
27
28
 
28
29
  private
29
30
 
30
- def table_name
31
- @model.quoted_table_name
31
+ def attribute
32
+ return @column_name if @column_name.is_a?(Arel::Nodes::SqlLiteral)
33
+
34
+ @model.arel_table[@name]
32
35
  end
33
36
 
34
- def column_name
35
- @connection.quote_column_name(@name)
37
+ def cast_to_text(node)
38
+ Arel::Nodes::NamedFunction.new("cast", [node.as(Arel.sql("text"))])
36
39
  end
37
40
 
38
- def expression
39
- full_name
41
+ def coalesce_to_blank_string(node)
42
+ Arel::Nodes::NamedFunction.new("coalesce", [node, Arel::Nodes.build_quoted("")])
40
43
  end
44
+
45
+ def source_table = @model.arel_table
41
46
  end
42
47
  end
43
48
  end
@@ -18,13 +18,11 @@ module PgSearch
18
18
 
19
19
  private
20
20
 
21
- def expression
22
- "#{@association.subselect_alias}.#{self.alias}"
21
+ def attribute
22
+ @model.arel_table.alias(@association.subselect_alias)[self.alias]
23
23
  end
24
24
 
25
- def table_name
26
- @connection.quote_table_name(@association.table_name)
27
- end
25
+ def source_table = @association.arel_table
28
26
  end
29
27
  end
30
28
  end
@@ -25,15 +25,11 @@ module PgSearch
25
25
  @normalizer_to_wrap = normalizer_to_wrap
26
26
  end
27
27
 
28
- def add_normalization(original_sql)
29
- otherwise_normalized_sql = Arel.sql(
30
- normalizer_to_wrap.add_normalization(original_sql)
31
- )
32
-
28
+ def add_normalization(expression)
33
29
  Arel::Nodes::NamedFunction.new(
34
30
  "pg_search_dmetaphone",
35
- [otherwise_normalized_sql]
36
- ).to_sql
31
+ [normalizer_to_wrap.add_normalization(expression)]
32
+ )
37
33
  end
38
34
 
39
35
  private
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/module/delegation"
4
3
  require "active_support/core_ext/hash/keys"
5
4
 
6
5
  module PgSearch
@@ -10,8 +9,6 @@ module PgSearch
10
9
  %i[only sort_only]
11
10
  end
12
11
 
13
- delegate :connection, :quoted_table_name, to: :@model
14
-
15
12
  def initialize(query, options, all_columns, model, normalizer)
16
13
  @query = query
17
14
  @options = (options || {}).assert_valid_keys(self.class.valid_options)
@@ -25,7 +22,14 @@ module PgSearch
25
22
  attr_reader :query, :options, :all_columns, :model, :normalizer
26
23
 
27
24
  def document
28
- columns.map(&:to_sql).join(" || ' ' || ")
25
+ space = Arel::Nodes.build_quoted(" ")
26
+ columns.map(&:to_arel).inject do |memo, col|
27
+ Arel::Nodes::InfixOperation.new(
28
+ "||",
29
+ Arel::Nodes::InfixOperation.new("||", memo, space),
30
+ col
31
+ )
32
+ end
29
33
  end
30
34
 
31
35
  def columns
@@ -29,43 +29,29 @@ module PgSearch
29
29
 
30
30
  private
31
31
 
32
- def word_similarity?
33
- options[:word_similarity]
34
- end
32
+ def word_similarity? = options[:word_similarity]
35
33
 
36
34
  def similarity_function
37
- if word_similarity?
38
- "word_similarity"
39
- else
40
- "similarity"
41
- end
35
+ word_similarity? ? "word_similarity" : "similarity"
42
36
  end
43
37
 
44
38
  def infix_operator
45
- if word_similarity?
46
- "<%"
47
- else
48
- "%"
49
- end
39
+ word_similarity? ? "<%" : "%"
50
40
  end
51
41
 
52
42
  def similarity
53
43
  Arel::Nodes::NamedFunction.new(
54
44
  similarity_function,
55
- [
56
- normalized_query,
57
- normalized_document
58
- ]
45
+ [normalized_query, normalized_document]
59
46
  )
60
47
  end
61
48
 
62
49
  def normalized_document
63
- Arel::Nodes::Grouping.new(Arel.sql(normalize(document)))
50
+ Arel::Nodes::Grouping.new(normalize(document))
64
51
  end
65
52
 
66
53
  def normalized_query
67
- sanitized_query = connection.quote(query)
68
- Arel.sql(normalize(sanitized_query))
54
+ normalize(Arel::Nodes.build_quoted(query))
69
55
  end
70
56
  end
71
57
  end
@@ -12,16 +12,16 @@ module PgSearch
12
12
 
13
13
  def conditions
14
14
  Arel::Nodes::Grouping.new(
15
- Arel::Nodes::InfixOperation.new("@@", arel_wrap(tsdocument), arel_wrap(tsquery))
15
+ Arel::Nodes::InfixOperation.new("@@", Arel::Nodes::Grouping.new(tsdocument), Arel::Nodes::Grouping.new(tsquery))
16
16
  )
17
17
  end
18
18
 
19
19
  def rank
20
- arel_wrap(tsearch_rank)
20
+ Arel::Nodes::Grouping.new(tsearch_rank)
21
21
  end
22
22
 
23
23
  def highlight
24
- arel_wrap(ts_headline)
24
+ Arel::Nodes::Grouping.new(ts_headline)
25
25
  end
26
26
 
27
27
  private
@@ -29,10 +29,10 @@ module PgSearch
29
29
  def ts_headline
30
30
  Arel::Nodes::NamedFunction.new("ts_headline", [
31
31
  dictionary,
32
- arel_wrap(document),
33
- arel_wrap(tsquery),
32
+ Arel::Nodes::Grouping.new(document),
33
+ Arel::Nodes::Grouping.new(tsquery),
34
34
  Arel::Nodes.build_quoted(ts_headline_options)
35
- ]).to_sql
35
+ ])
36
36
  end
37
37
 
38
38
  def ts_headline_options
@@ -97,22 +97,20 @@ module PgSearch
97
97
  end
98
98
 
99
99
  sanitized_term = unsanitized_term.gsub(DISALLOWED_TSQUERY_CHARACTERS, " ")
100
+ term_node = normalize(Arel::Nodes.build_quoted(sanitized_term))
101
+ tsquery_expr = tsquery_expression(term_node, negated: negated, prefix: options[:prefix])
100
102
 
101
- term_sql = Arel.sql(normalize(connection.quote(sanitized_term)))
102
-
103
- tsquery = tsquery_expression(term_sql, negated: negated, prefix: options[:prefix])
104
-
105
- Arel::Nodes::NamedFunction.new("to_tsquery", [dictionary, tsquery]).to_sql
103
+ Arel::Nodes::NamedFunction.new("to_tsquery", [dictionary, tsquery_expr])
106
104
  end
107
105
 
108
106
  # After this, the SQL expression evaluates to a string containing the term surrounded by single-quotes.
109
107
  # If :prefix is true, then the term will have :* appended to the end.
110
108
  # If :negated is true, then the term will have ! prepended to the front.
111
- def tsquery_expression(term_sql, negated:, prefix:)
109
+ def tsquery_expression(term_node, negated:, prefix:)
112
110
  terms = [
113
111
  (Arel::Nodes.build_quoted("!") if negated),
114
112
  Arel::Nodes.build_quoted("' "),
115
- term_sql,
113
+ term_node,
116
114
  Arel::Nodes.build_quoted(" '"),
117
115
  (Arel::Nodes.build_quoted(":*") if prefix)
118
116
  ].compact
@@ -123,29 +121,26 @@ module PgSearch
123
121
  end
124
122
 
125
123
  def tsquery
126
- return "''" if query.blank?
124
+ return Arel::Nodes.build_quoted("") if query.blank?
127
125
 
128
126
  query_terms = query.split.compact
129
- tsquery_terms = query_terms.map { |term| tsquery_for_term(term) }
130
- tsquery_terms.join(options[:any_word] ? " || " : " && ")
127
+ query_terms.map { |term| tsquery_for_term(term) }
128
+ .inject { |memo, t| Arel::Nodes::InfixOperation.new(options[:any_word] ? "||" : "&&", memo, t) }
131
129
  end
132
130
 
133
131
  def tsdocument
134
- tsdocument_terms = (columns_to_use || []).map do |search_column|
135
- column_to_tsvector(search_column)
136
- end
132
+ terms = (columns_to_use || []).map { |col| column_to_tsvector(col) }
137
133
 
138
134
  if options[:tsvector_column]
139
- tsvector_columns = Array.wrap(options[:tsvector_column])
140
-
141
- tsdocument_terms << tsvector_columns.map do |tsvector_column|
142
- column_name = connection.quote_column_name(tsvector_column)
143
-
144
- "#{quoted_table_name}.#{column_name}"
135
+ Array.wrap(options[:tsvector_column]).each do |tsvector_column|
136
+ terms << Arel::Attributes::Attribute.new(
137
+ model.arel_table,
138
+ tsvector_column.to_s
139
+ )
145
140
  end
146
141
  end
147
142
 
148
- tsdocument_terms.join(" || ")
143
+ terms.inject { |memo, t| Arel::Nodes::InfixOperation.new("||", memo, t) } || Arel.sql("")
149
144
  end
150
145
 
151
146
  # From http://www.postgresql.org/docs/8.3/static/textsearch-controls.html
@@ -163,20 +158,16 @@ module PgSearch
163
158
 
164
159
  def tsearch_rank
165
160
  Arel::Nodes::NamedFunction.new("ts_rank", [
166
- arel_wrap(tsdocument),
167
- arel_wrap(tsquery),
161
+ Arel::Nodes::Grouping.new(tsdocument),
162
+ Arel::Nodes::Grouping.new(tsquery),
168
163
  normalization
169
- ]).to_sql
164
+ ])
170
165
  end
171
166
 
172
167
  def dictionary
173
168
  Arel::Nodes.build_quoted(options[:dictionary] || :simple)
174
169
  end
175
170
 
176
- def arel_wrap(sql_string)
177
- Arel::Nodes::Grouping.new(Arel.sql(sql_string))
178
- end
179
-
180
171
  def columns_to_use
181
172
  if options[:tsvector_column]
182
173
  columns.select { |c| c.is_a?(PgSearch::Configuration::ForeignColumn) }
@@ -188,13 +179,16 @@ module PgSearch
188
179
  def column_to_tsvector(search_column)
189
180
  tsvector = Arel::Nodes::NamedFunction.new(
190
181
  "to_tsvector",
191
- [dictionary, Arel.sql(normalize(search_column.to_sql))]
192
- ).to_sql
182
+ [dictionary, normalize(search_column.to_arel)]
183
+ )
193
184
 
194
185
  if search_column.weight.nil?
195
186
  tsvector
196
187
  else
197
- "setweight(#{tsvector}, #{connection.quote(search_column.weight)})"
188
+ Arel::Nodes::NamedFunction.new(
189
+ "setweight",
190
+ [tsvector, Arel::Nodes.build_quoted(search_column.weight)]
191
+ )
198
192
  end
199
193
  end
200
194
  end
@@ -7,6 +7,8 @@ require "pg_search/features/trigram"
7
7
  require "pg_search/features/tsearch"
8
8
 
9
9
  module PgSearch
10
+ # Search features provide conditions and rank as composable Arel expressions,
11
+ # not SQL Strings. TSearch also provides a highlight expression for selection.
10
12
  module Features
11
13
  end
12
14
  end
@@ -28,11 +28,7 @@ module PgSearch
28
28
  end
29
29
 
30
30
  def migration_version
31
- if ActiveRecord::VERSION::MAJOR >= 5
32
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
33
- else
34
- ""
35
- end
31
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
36
32
  end
37
33
  end
38
34
  end
@@ -3,6 +3,16 @@
3
3
  module PgSearch
4
4
  module Multisearch
5
5
  class Rebuilder
6
+ # Arguments
7
+ #
8
+ # +model+:: Active Record model configured for multisearch.
9
+ # +time_source+:: Clock returning a timestamp for bulk inserts. Defaults to
10
+ # +Time.method(:now)+ and is called once per bulk rebuild so
11
+ # +created_at+ and +updated_at+ share a timestamp.
12
+ #
13
+ # Raises
14
+ #
15
+ # ModelNotMultisearchable:: If +model+ is not configured for multisearch.
6
16
  def initialize(model, time_source = Time.method(:now))
7
17
  raise ModelNotMultisearchable, model unless model.respond_to?(:pg_search_multisearchable_options)
8
18
 
@@ -10,6 +20,10 @@ module PgSearch
10
20
  @time_source = time_source
11
21
  end
12
22
 
23
+ # Populates search documents without first clearing existing documents.
24
+ # Uses the model's rebuild_pg_search_documents override when available;
25
+ # otherwise updates records individually for conditions, dynamic content,
26
+ # or additional attributes, and bulk-inserts for plain column content.
13
27
  def rebuild
14
28
  if model.respond_to?(:rebuild_pg_search_documents)
15
29
  model.rebuild_pg_search_documents
@@ -25,7 +39,8 @@ module PgSearch
25
39
  attr_reader :model
26
40
 
27
41
  def conditional?
28
- model.pg_search_multisearchable_options.key?(:if) || model.pg_search_multisearchable_options.key?(:unless)
42
+ model.pg_search_multisearchable_options.key?(:if) ||
43
+ model.pg_search_multisearchable_options.key?(:unless)
29
44
  end
30
45
 
31
46
  def dynamic?
@@ -37,77 +52,66 @@ module PgSearch
37
52
  model.pg_search_multisearchable_options.key?(:additional_attributes)
38
53
  end
39
54
 
40
- def connection
41
- model.connection
42
- end
43
-
44
- def primary_key
45
- connection.quote_column_name(model.primary_key)
46
- end
47
-
48
- def rebuild_sql_template
49
- <<~SQL.squish
50
- INSERT INTO :documents_table (searchable_type, searchable_id, content, created_at, updated_at)
51
- SELECT :base_model_name AS searchable_type,
52
- :model_table.#{primary_key} AS searchable_id,
53
- (
54
- :content_expressions
55
- ) AS content,
56
- :current_time AS created_at,
57
- :current_time AS updated_at
58
- FROM :model_table :sti_clause
59
- SQL
60
- end
61
-
62
- def rebuild_sql
63
- replacements.inject(rebuild_sql_template) do |sql, key|
64
- sql.gsub ":#{key}", send(key)
55
+ def connection = model.connection
56
+
57
+ def rebuild_sql = connection.to_sql(insert_manager.ast)
58
+
59
+ def insert_manager
60
+ time = @time_source.call
61
+ quoted_time = Arel::Nodes.build_quoted(time)
62
+ src = model.arel_table
63
+ doc = PgSearch::Document.arel_table
64
+
65
+ sel = Arel::SelectManager.new(src)
66
+ sel.project(
67
+ Arel::Nodes.build_quoted(model.base_class.name).as("searchable_type"),
68
+ src[model.primary_key].as("searchable_id"),
69
+ content_expression.as("content"),
70
+ quoted_time.as("created_at"),
71
+ quoted_time.as("updated_at")
72
+ )
73
+
74
+ apply_sti_condition(sel, src)
75
+
76
+ mgr = Arel::InsertManager.new
77
+ mgr.into(doc)
78
+ mgr.columns.concat([
79
+ doc[:searchable_type],
80
+ doc[:searchable_id],
81
+ doc[:content],
82
+ doc[:created_at],
83
+ doc[:updated_at]
84
+ ])
85
+ mgr.select(sel.ast)
86
+ mgr
87
+ end
88
+
89
+ def content_expression
90
+ expressions = columns.map { |column| Configuration::Column.new(column, nil, model).to_arel }
91
+ expressions.inject do |document, expression|
92
+ Arel::Nodes::InfixOperation.new(
93
+ "||",
94
+ Arel::Nodes::InfixOperation.new("||", document, Arel::Nodes.build_quoted(" ")),
95
+ expression
96
+ )
65
97
  end
66
98
  end
67
99
 
68
- def sti_clause
69
- clause = ""
70
- if model.column_names.include? model.inheritance_column
71
- quoted_inheritance_column = connection.quote_column_name(model.inheritance_column)
72
- clause = "WHERE"
73
- clause = "#{clause} #{quoted_inheritance_column} IS NULL OR" if model.base_class == model
74
- clause = "#{clause} #{quoted_inheritance_column} = #{model_name}"
75
- end
76
- clause
77
- end
100
+ def columns = Array(model.pg_search_multisearchable_options[:against])
78
101
 
79
- def replacements
80
- %w[content_expressions base_model_name model_name model_table documents_table current_time sti_clause]
81
- end
102
+ def apply_sti_condition(select_manager, src_table)
103
+ return unless model.column_names.include?(model.inheritance_column)
82
104
 
83
- def content_expressions
84
- columns.map do |column|
85
- %{coalesce(:model_table.#{connection.quote_column_name(column)}::text, '')}
86
- end.join(" || ' ' || ")
87
- end
105
+ inheritance_col = src_table[model.inheritance_column]
106
+ type_match = inheritance_col.eq(model.name)
88
107
 
89
- def columns
90
- Array(model.pg_search_multisearchable_options[:against])
91
- end
92
-
93
- def model_name
94
- connection.quote(model.name)
95
- end
96
-
97
- def base_model_name
98
- connection.quote(model.base_class.name)
99
- end
100
-
101
- def model_table
102
- model.quoted_table_name
103
- end
104
-
105
- def documents_table
106
- PgSearch::Document.quoted_table_name
107
- end
108
+ condition = if model.base_class == model
109
+ inheritance_col.eq(nil).or(type_match)
110
+ else
111
+ type_match
112
+ end
108
113
 
109
- def current_time
110
- connection.quote(connection.quoted_date(@time_source.call))
114
+ select_manager.where(condition)
111
115
  end
112
116
  end
113
117
  end
@@ -2,38 +2,57 @@
2
2
 
3
3
  module PgSearch
4
4
  class Normalizer
5
+ # Arguments
6
+ #
7
+ # +config+:: Search configuration whose ignore list controls accent
8
+ # normalization.
5
9
  def initialize(config)
6
10
  @config = config
7
11
  end
8
12
 
9
- DISALLOWED_CHARACTERS = "'[''?\\:]'"
13
+ DISALLOWED_CHARACTERS = "['?\\:]"
10
14
 
11
- def add_normalization(sql_expression)
12
- return sql_expression unless config.ignore.include?(:accents)
13
-
14
- sql_node = case sql_expression
15
- when Arel::Nodes::Node
16
- sql_expression
17
- else
18
- Arel.sql(sql_expression)
19
- end
15
+ # Applies configured accent normalization, preserving the input expression
16
+ # without an unaccent wrapper when accents are not ignored.
17
+ #
18
+ # Arguments
19
+ #
20
+ # +expression+:: An Arel node, attribute, or trusted SQL String. A String is
21
+ # an SQL expression, not a value to quote.
22
+ #
23
+ # Returns a composable Arel expression.
24
+ #
25
+ # Raises
26
+ #
27
+ # TypeError:: If +expression+ is not an Arel node, attribute, or String.
28
+ def add_normalization(expression)
29
+ node = to_node(expression)
30
+ return node unless config.ignore.include?(:accents)
20
31
 
21
32
  Arel::Nodes::NamedFunction.new(
22
33
  "regexp_replace",
23
34
  [
24
- Arel::Nodes::NamedFunction.new(
25
- PgSearch.unaccent_function,
26
- [sql_node]
27
- ),
28
- Arel.sql(DISALLOWED_CHARACTERS),
29
- Arel.sql("''"),
30
- Arel.sql("'g'")
35
+ Arel::Nodes::NamedFunction.new(PgSearch.unaccent_function, [node]),
36
+ Arel::Nodes.build_quoted(DISALLOWED_CHARACTERS),
37
+ Arel::Nodes.build_quoted(""),
38
+ Arel::Nodes.build_quoted("g")
31
39
  ]
32
- ).to_sql
40
+ )
33
41
  end
34
42
 
35
43
  private
36
44
 
37
45
  attr_reader :config
46
+
47
+ def to_node(expression)
48
+ case expression
49
+ when Arel::Nodes::Node, Arel::Attributes::Attribute
50
+ expression
51
+ when String
52
+ Arel.sql(expression)
53
+ else
54
+ raise TypeError
55
+ end
56
+ end
38
57
  end
39
58
  end