pg_search 2.3.7 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +38 -37
  3. data/.github/workflows/codeql.yml +86 -0
  4. data/.gitignore +0 -1
  5. data/.standard.yml +2 -2
  6. data/CHANGELOG.md +81 -64
  7. data/CONTRIBUTING.md +1 -1
  8. data/Gemfile +4 -2
  9. data/LICENSE +1 -1
  10. data/README.md +89 -87
  11. data/Rakefile +1 -7
  12. data/doc/plans/2026-09-05-arel-stack.md +43 -0
  13. data/doc/plans/2026-09-06-arel-remaining-expressions.md +48 -0
  14. data/lib/pg_search/configuration/association.rb +37 -23
  15. data/lib/pg_search/configuration/column.rb +17 -12
  16. data/lib/pg_search/configuration/foreign_column.rb +3 -5
  17. data/lib/pg_search/configuration.rb +10 -26
  18. data/lib/pg_search/features/dmetaphone.rb +3 -7
  19. data/lib/pg_search/features/feature.rb +8 -4
  20. data/lib/pg_search/features/trigram.rb +6 -20
  21. data/lib/pg_search/features/tsearch.rb +47 -61
  22. data/lib/pg_search/features.rb +2 -0
  23. data/lib/pg_search/migration/generator.rb +1 -5
  24. data/lib/pg_search/model.rb +6 -8
  25. data/lib/pg_search/multisearch/rebuilder.rb +69 -64
  26. data/lib/pg_search/normalizer.rb +40 -11
  27. data/lib/pg_search/scope_options.rb +32 -49
  28. data/lib/pg_search/version.rb +1 -1
  29. data/pg_search.gemspec +5 -5
  30. data/spec/integration/associations_spec.rb +133 -0
  31. data/spec/integration/deprecation_spec.rb +5 -1
  32. data/spec/integration/pagination_spec.rb +1 -0
  33. data/spec/integration/pg_search_spec.rb +78 -1
  34. data/spec/integration/single_table_inheritance_spec.rb +2 -0
  35. data/spec/lib/pg_search/configuration/association_spec.rb +34 -58
  36. data/spec/lib/pg_search/configuration/column_spec.rb +73 -15
  37. data/spec/lib/pg_search/configuration/foreign_column_spec.rb +53 -20
  38. data/spec/lib/pg_search/features/dmetaphone_spec.rb +2 -2
  39. data/spec/lib/pg_search/features/trigram_spec.rb +8 -9
  40. data/spec/lib/pg_search/features/tsearch_spec.rb +8 -8
  41. data/spec/lib/pg_search/multisearch/rebuilder_spec.rb +251 -211
  42. data/spec/lib/pg_search/multisearch_spec.rb +16 -16
  43. data/spec/lib/pg_search/multisearchable_spec.rb +8 -0
  44. data/spec/lib/pg_search/normalizer_spec.rb +64 -11
  45. data/spec/lib/pg_search_spec.rb +6 -21
  46. data/spec/spec_helper.rb +0 -13
  47. metadata +11 -15
  48. data/spec/.rubocop.yml +0 -27
  49. data/spec/integration/.rubocop.yml +0 -11
@@ -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
@@ -49,12 +49,8 @@ module PgSearch
49
49
 
50
50
  %w[
51
51
  StartSel StopSel MaxFragments MaxWords MinWords ShortWord FragmentDelimiter HighlightAll
52
- ].reduce({}) do |hash, key|
53
- hash.tap do
54
- value = indifferent_options[:highlight][key]
55
-
56
- hash[key] = ts_headline_option_value(value)
57
- end
52
+ ].to_h do |key|
53
+ [key, ts_headline_option_value(indifferent_options[:highlight][key])]
58
54
  end
59
55
  end
60
56
 
@@ -63,23 +59,20 @@ module PgSearch
63
59
 
64
60
  %w[
65
61
  start_sel stop_sel max_fragments max_words min_words short_word fragment_delimiter highlight_all
66
- ].reduce({}) do |hash, deprecated_key|
67
- hash.tap do
68
- value = indifferent_options[:highlight][deprecated_key]
69
-
70
- unless value.nil?
71
- key = deprecated_key.camelize
72
-
73
- warn(
74
- "pg_search 3.0 will no longer accept :#{deprecated_key} as an argument to :ts_headline, " \
75
- "use :#{key} instead.",
76
- category: :deprecated,
77
- uplevel: 1
78
- )
79
-
80
- hash[key] = ts_headline_option_value(value)
81
- end
82
- end
62
+ ].each_with_object({}) do |deprecated_key, hash|
63
+ value = indifferent_options[:highlight][deprecated_key]
64
+ next if value.nil?
65
+
66
+ key = deprecated_key.camelize
67
+
68
+ warn(
69
+ "pg_search 3.0 will no longer accept :#{deprecated_key} as an argument to :ts_headline, " \
70
+ "use :#{key} instead.",
71
+ category: :deprecated,
72
+ uplevel: 1
73
+ )
74
+
75
+ hash[key] = ts_headline_option_value(value)
83
76
  end
84
77
  end
85
78
 
@@ -96,8 +89,7 @@ module PgSearch
96
89
  end
97
90
  end
98
91
 
99
- DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’ʻʼ]/
100
-
92
+ DISALLOWED_TSQUERY_CHARACTERS = /['?\\:]/ # standard:disable Lint/UselessConstantScoping
101
93
  def tsquery_for_term(unsanitized_term)
102
94
  if options[:negation] && unsanitized_term.start_with?("!")
103
95
  unsanitized_term[0] = ""
@@ -105,22 +97,20 @@ module PgSearch
105
97
  end
106
98
 
107
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])
108
102
 
109
- term_sql = Arel.sql(normalize(connection.quote(sanitized_term)))
110
-
111
- tsquery = tsquery_expression(term_sql, negated: negated, prefix: options[:prefix])
112
-
113
- Arel::Nodes::NamedFunction.new("to_tsquery", [dictionary, tsquery]).to_sql
103
+ Arel::Nodes::NamedFunction.new("to_tsquery", [dictionary, tsquery_expr])
114
104
  end
115
105
 
116
106
  # After this, the SQL expression evaluates to a string containing the term surrounded by single-quotes.
117
107
  # If :prefix is true, then the term will have :* appended to the end.
118
108
  # If :negated is true, then the term will have ! prepended to the front.
119
- def tsquery_expression(term_sql, negated:, prefix:)
109
+ def tsquery_expression(term_node, negated:, prefix:)
120
110
  terms = [
121
111
  (Arel::Nodes.build_quoted("!") if negated),
122
112
  Arel::Nodes.build_quoted("' "),
123
- term_sql,
113
+ term_node,
124
114
  Arel::Nodes.build_quoted(" '"),
125
115
  (Arel::Nodes.build_quoted(":*") if prefix)
126
116
  ].compact
@@ -131,29 +121,26 @@ module PgSearch
131
121
  end
132
122
 
133
123
  def tsquery
134
- return "''" if query.blank?
124
+ return Arel::Nodes.build_quoted("") if query.blank?
135
125
 
136
126
  query_terms = query.split.compact
137
- tsquery_terms = query_terms.map { |term| tsquery_for_term(term) }
138
- 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) }
139
129
  end
140
130
 
141
131
  def tsdocument
142
- tsdocument_terms = (columns_to_use || []).map do |search_column|
143
- column_to_tsvector(search_column)
144
- end
132
+ terms = (columns_to_use || []).map { |col| column_to_tsvector(col) }
145
133
 
146
134
  if options[:tsvector_column]
147
- tsvector_columns = Array.wrap(options[:tsvector_column])
148
-
149
- tsdocument_terms << tsvector_columns.map do |tsvector_column|
150
- column_name = connection.quote_column_name(tsvector_column)
151
-
152
- "#{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
+ )
153
140
  end
154
141
  end
155
142
 
156
- tsdocument_terms.join(" || ")
143
+ terms.inject { |memo, t| Arel::Nodes::InfixOperation.new("||", memo, t) } || Arel.sql("")
157
144
  end
158
145
 
159
146
  # From http://www.postgresql.org/docs/8.3/static/textsearch-controls.html
@@ -171,20 +158,16 @@ module PgSearch
171
158
 
172
159
  def tsearch_rank
173
160
  Arel::Nodes::NamedFunction.new("ts_rank", [
174
- arel_wrap(tsdocument),
175
- arel_wrap(tsquery),
161
+ Arel::Nodes::Grouping.new(tsdocument),
162
+ Arel::Nodes::Grouping.new(tsquery),
176
163
  normalization
177
- ]).to_sql
164
+ ])
178
165
  end
179
166
 
180
167
  def dictionary
181
168
  Arel::Nodes.build_quoted(options[:dictionary] || :simple)
182
169
  end
183
170
 
184
- def arel_wrap(sql_string)
185
- Arel::Nodes::Grouping.new(Arel.sql(sql_string))
186
- end
187
-
188
171
  def columns_to_use
189
172
  if options[:tsvector_column]
190
173
  columns.select { |c| c.is_a?(PgSearch::Configuration::ForeignColumn) }
@@ -196,13 +179,16 @@ module PgSearch
196
179
  def column_to_tsvector(search_column)
197
180
  tsvector = Arel::Nodes::NamedFunction.new(
198
181
  "to_tsvector",
199
- [dictionary, Arel.sql(normalize(search_column.to_sql))]
200
- ).to_sql
182
+ [dictionary, normalize(search_column.to_arel)]
183
+ )
201
184
 
202
185
  if search_column.weight.nil?
203
186
  tsvector
204
187
  else
205
- "setweight(#{tsvector}, #{connection.quote(search_column.weight)})"
188
+ Arel::Nodes::NamedFunction.new(
189
+ "setweight",
190
+ [tsvector, Arel::Nodes.build_quoted(search_column.weight)]
191
+ )
206
192
  end
207
193
  end
208
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
@@ -23,13 +23,14 @@ module PgSearch
23
23
 
24
24
  def multisearchable(options = {})
25
25
  include PgSearch::Multisearchable
26
+
26
27
  class_attribute :pg_search_multisearchable_options
27
28
  self.pg_search_multisearchable_options = options
28
29
  end
29
30
  end
30
31
 
31
- def method_missing(symbol, *args)
32
- case symbol
32
+ def method_missing(method, *, **)
33
+ case method
33
34
  when :pg_search_rank
34
35
  raise PgSearchRankNotSelected unless respond_to?(:pg_search_rank)
35
36
 
@@ -43,12 +44,9 @@ module PgSearch
43
44
  end
44
45
  end
45
46
 
46
- def respond_to_missing?(symbol, *args)
47
- case symbol
48
- when :pg_search_rank
49
- attributes.key?(:pg_search_rank)
50
- when :pg_search_highlight
51
- attributes.key?(:pg_search_highlight)
47
+ def respond_to_missing?(method, *, **)
48
+ if method.in?(%i[pg_search_rank pg_search_highlight])
49
+ attributes.key?(method)
52
50
  else
53
51
  super
54
52
  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,76 +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
- 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
- clause = "WHERE"
72
- clause = "#{clause} #{model.inheritance_column} IS NULL OR" if model.base_class == model
73
- clause = "#{clause} #{model.inheritance_column} = #{model_name}"
74
- end
75
- clause
76
- end
100
+ def columns = Array(model.pg_search_multisearchable_options[:against])
77
101
 
78
- def replacements
79
- %w[content_expressions base_model_name model_name model_table documents_table current_time sti_clause]
80
- end
102
+ def apply_sti_condition(select_manager, src_table)
103
+ return unless model.column_names.include?(model.inheritance_column)
81
104
 
82
- def content_expressions
83
- columns.map do |column|
84
- %{coalesce(:model_table.#{connection.quote_column_name(column)}::text, '')}
85
- end.join(" || ' ' || ")
86
- end
105
+ inheritance_col = src_table[model.inheritance_column]
106
+ type_match = inheritance_col.eq(model.name)
87
107
 
88
- def columns
89
- Array(model.pg_search_multisearchable_options[:against])
90
- end
91
-
92
- def model_name
93
- connection.quote(model.name)
94
- end
95
-
96
- def base_model_name
97
- connection.quote(model.base_class.name)
98
- end
99
-
100
- def model_table
101
- model.quoted_table_name
102
- end
103
-
104
- def documents_table
105
- PgSearch::Document.quoted_table_name
106
- end
108
+ condition = if model.base_class == model
109
+ inheritance_col.eq(nil).or(type_match)
110
+ else
111
+ type_match
112
+ end
107
113
 
108
- def current_time
109
- connection.quote(connection.quoted_date(@time_source.call))
114
+ select_manager.where(condition)
110
115
  end
111
116
  end
112
117
  end
@@ -2,28 +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
- def add_normalization(sql_expression)
10
- return sql_expression unless config.ignore.include?(:accents)
13
+ DISALLOWED_CHARACTERS = "['?\\:]"
11
14
 
12
- sql_node = case sql_expression
13
- when Arel::Nodes::Node
14
- sql_expression
15
- else
16
- Arel.sql(sql_expression)
17
- 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)
18
31
 
19
32
  Arel::Nodes::NamedFunction.new(
20
- PgSearch.unaccent_function,
21
- [sql_node]
22
- ).to_sql
33
+ "regexp_replace",
34
+ [
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")
39
+ ]
40
+ )
23
41
  end
24
42
 
25
43
  private
26
44
 
27
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
28
57
  end
29
58
  end