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
@@ -3,37 +3,69 @@
3
3
  require "spec_helper"
4
4
 
5
5
  describe PgSearch::Configuration::ForeignColumn do
6
- describe "#alias" do
7
- with_model :AssociatedModel do
8
- table do |t|
9
- t.string "title"
10
- end
6
+ with_model :AssociatedModel do
7
+ table do |t|
8
+ t.string :title
11
9
  end
10
+ end
12
11
 
13
- with_model :Model do
14
- table do |t|
15
- t.string "title"
16
- t.belongs_to :another_model, index: false
17
- end
12
+ with_model :Model do
13
+ table do |t|
14
+ t.string :name
15
+ t.belongs_to :associated_model, index: false, null: true
16
+ end
18
17
 
19
- model do
20
- include PgSearch::Model
18
+ model do
19
+ include PgSearch::Model
21
20
 
22
- belongs_to :another_model, class_name: "AssociatedModel"
21
+ belongs_to :associated_model, class_name: "AssociatedModel", optional: true
23
22
 
24
- pg_search_scope :with_another, associated_against: {another_model: :title}
25
- end
23
+ pg_search_scope :with_title, associated_against: {associated_model: :title}
26
24
  end
25
+ end
27
26
 
28
- it "returns a consistent string" do
29
- association = PgSearch::Configuration::Association.new(Model,
30
- :another_model,
31
- :title)
32
- foreign_column = described_class.new("title", nil, Model, association)
27
+ let(:association) do
28
+ PgSearch::Configuration::Association.new(Model, :associated_model, :title)
29
+ end
30
+ let(:foreign_column) { described_class.new("title", nil, Model, association) }
33
31
 
32
+ describe "#alias" do
33
+ it "returns a consistent string" do
34
34
  column_alias = foreign_column.alias
35
35
  expect(column_alias).to be_a String
36
36
  expect(foreign_column.alias).to eq column_alias
37
37
  end
38
38
  end
39
+
40
+ describe "#to_arel" do
41
+ def selected_value(model)
42
+ relation = Model
43
+ .joins(association.to_arel(Model.arel_table[:id]))
44
+ .where(Model.arel_table[:id].eq(model.id))
45
+ .select(foreign_column.to_arel.as("value"))
46
+ sql = Model.connection.visitor.compile(relation.arel.ast)
47
+
48
+ Model.connection.select_value(sql)
49
+ end
50
+
51
+ it "evaluates the associated value through the association join alias" do
52
+ associated = AssociatedModel.create!(title: "hello world")
53
+ model = Model.create!(name: "example", associated_model: associated)
54
+
55
+ expect(selected_value(model)).to eq("hello world")
56
+ end
57
+
58
+ it "coalesces a NULL associated value through the association join alias" do
59
+ associated = AssociatedModel.create!(title: nil)
60
+ model = Model.create!(name: "example", associated_model: associated)
61
+
62
+ expect(selected_value(model)).to eq("")
63
+ end
64
+
65
+ it "coalesces a missing association through the outer join alias" do
66
+ model = Model.create!(name: "example", associated_model: nil)
67
+
68
+ expect(selected_value(model)).to eq("")
69
+ end
70
+ end
39
71
  end
@@ -23,7 +23,7 @@ describe PgSearch::Features::DMetaphone do
23
23
 
24
24
  feature = described_class.new(query, options, columns, Model, normalizer)
25
25
  expect(feature.rank.to_sql).to eq(
26
- %{(ts_rank((to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."name")::text, ''))) || to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."content")::text, '')))), (to_tsquery('simple', ''' ' || pg_search_dmetaphone('query') || ' ''')), 0))}
26
+ %{(ts_rank((to_tsvector('simple', pg_search_dmetaphone(coalesce(cast(#{Model.quoted_table_name}."name" AS text), ''))) || to_tsvector('simple', pg_search_dmetaphone(coalesce(cast(#{Model.quoted_table_name}."content" AS text), '')))), (to_tsquery('simple', ''' ' || pg_search_dmetaphone('query') || ' ''')), 0))}
27
27
  )
28
28
  end
29
29
  end
@@ -48,7 +48,7 @@ describe PgSearch::Features::DMetaphone do
48
48
 
49
49
  feature = described_class.new(query, options, columns, Model, normalizer)
50
50
  expect(feature.conditions.to_sql).to eq(
51
- %{((to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."name")::text, ''))) || to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."content")::text, '')))) @@ (to_tsquery('simple', ''' ' || pg_search_dmetaphone('query') || ' ''')))}
51
+ %{((to_tsvector('simple', pg_search_dmetaphone(coalesce(cast(#{Model.quoted_table_name}."name" AS text), ''))) || to_tsvector('simple', pg_search_dmetaphone(coalesce(cast(#{Model.quoted_table_name}."content" AS text), '')))) @@ (to_tsquery('simple', ''' ' || pg_search_dmetaphone('query') || ' ''')))}
52
52
  )
53
53
  end
54
54
  end
@@ -20,9 +20,9 @@ describe PgSearch::Features::Trigram do
20
20
 
21
21
  let(:coalesced_columns) do
22
22
  <<~SQL.squish
23
- coalesce((#{Model.quoted_table_name}."name")::text, '')
23
+ coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')
24
24
  || ' '
25
- || coalesce((#{Model.quoted_table_name}."content")::text, '')
25
+ || coalesce(cast(#{Model.quoted_table_name}."content" AS text), '')
26
26
  SQL
27
27
  end
28
28
 
@@ -87,7 +87,7 @@ describe PgSearch::Features::Trigram do
87
87
  let(:options) { {only: :name} }
88
88
 
89
89
  it "only searches against the select column" do
90
- coalesced_column = "coalesce((#{Model.quoted_table_name}.\"name\")::text, '')"
90
+ coalesced_column = "coalesce(cast(#{Model.quoted_table_name}.\"name\" AS text), '')"
91
91
  expect(feature.conditions.to_sql).to eq("('#{query}' % (#{coalesced_column}))")
92
92
  end
93
93
  end
@@ -24,7 +24,7 @@ describe PgSearch::Features::TSearch do
24
24
 
25
25
  feature = described_class.new(query, options, columns, Model, normalizer)
26
26
  expect(feature.rank.to_sql).to eq(
27
- %{(ts_rank((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 0))}
27
+ %{(ts_rank((to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')) || to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."content" AS text), ''))), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 0))}
28
28
  )
29
29
  end
30
30
 
@@ -67,7 +67,7 @@ describe PgSearch::Features::TSearch do
67
67
 
68
68
  feature = described_class.new(query, options, columns, Model, normalizer)
69
69
  expect(feature.conditions.to_sql).to eq(
70
- %{((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))}
70
+ %{((to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')) || to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."content" AS text), ''))) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))}
71
71
  )
72
72
  end
73
73
 
@@ -84,7 +84,7 @@ describe PgSearch::Features::TSearch do
84
84
 
85
85
  feature = described_class.new(query, options, columns, Model, normalizer)
86
86
  expect(feature.conditions.to_sql).to eq(
87
- %{((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))) @@ (to_tsquery('simple', '!' || ''' ' || 'query' || ' ''')))}
87
+ %{((to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')) || to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."content" AS text), ''))) @@ (to_tsquery('simple', '!' || ''' ' || 'query' || ' ''')))}
88
88
  )
89
89
  end
90
90
  end
@@ -102,7 +102,7 @@ describe PgSearch::Features::TSearch do
102
102
 
103
103
  feature = described_class.new(query, options, columns, Model, normalizer)
104
104
  expect(feature.conditions.to_sql).to eq(
105
- %{((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))) @@ (to_tsquery('simple', ''' ' || '!query' || ' ''')))}
105
+ %{((to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')) || to_tsvector('simple', coalesce(cast(#{Model.quoted_table_name}."content" AS text), ''))) @@ (to_tsquery('simple', ''' ' || '!query' || ' ''')))}
106
106
  )
107
107
  end
108
108
  end
@@ -164,7 +164,7 @@ describe PgSearch::Features::TSearch do
164
164
 
165
165
  feature = described_class.new(query, options, columns, Model, normalizer)
166
166
  expect(feature.highlight.to_sql).to eq(
167
- "(ts_headline('simple', (coalesce((#{Model.quoted_table_name}.\"name\")::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), ''))"
167
+ "(ts_headline('simple', (coalesce(cast(#{Model.quoted_table_name}.\"name\" AS text), '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), ''))"
168
168
  )
169
169
  end
170
170
 
@@ -189,7 +189,7 @@ describe PgSearch::Features::TSearch do
189
189
 
190
190
  feature = described_class.new(query, options, columns, Model, normalizer)
191
191
 
192
- expected_sql = %{(ts_headline('spanish', (coalesce((#{Model.quoted_table_name}."name")::text, '') || ' ' || coalesce((#{Model.quoted_table_name}."content")::text, '')), (to_tsquery('spanish', ''' ' || 'query' || ' ''')), 'StartSel = "<b>", StopSel = "</b>"'))}
192
+ expected_sql = %{(ts_headline('spanish', (coalesce(cast(#{Model.quoted_table_name}."name" AS text), '') || ' ' || coalesce(cast(#{Model.quoted_table_name}."content" AS text), '')), (to_tsquery('spanish', ''' ' || 'query' || ' ''')), 'StartSel = "<b>", StopSel = "</b>"'))}
193
193
 
194
194
  expect(feature.highlight.to_sql).to eq(expected_sql)
195
195
  end
@@ -221,7 +221,7 @@ describe PgSearch::Features::TSearch do
221
221
 
222
222
  feature = described_class.new(query, options, columns, Model, normalizer)
223
223
 
224
- expected_sql = %{(ts_headline('simple', (coalesce((#{Model.quoted_table_name}."name")::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = "<start class=""search"">", StopSel = "<stop>", MaxFragments = 3, MaxWords = 123, MinWords = 456, ShortWord = 4, FragmentDelimiter = "&hellip;", HighlightAll = TRUE'))}
224
+ expected_sql = %{(ts_headline('simple', (coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = "<start class=""search"">", StopSel = "<stop>", MaxFragments = 3, MaxWords = 123, MinWords = 456, ShortWord = 4, FragmentDelimiter = "&hellip;", HighlightAll = TRUE'))}
225
225
 
226
226
  expect(feature.highlight.to_sql).to eq(expected_sql)
227
227
  end
@@ -252,7 +252,7 @@ describe PgSearch::Features::TSearch do
252
252
  feature = described_class.new(query, options, columns, Model, normalizer)
253
253
 
254
254
  highlight_sql = silence_warnings { feature.highlight.to_sql }
255
- expected_sql = %{(ts_headline('simple', (coalesce((#{Model.quoted_table_name}."name")::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = "<start class=""search"">", StopSel = "<stop>", MaxFragments = 3, MaxWords = 123, MinWords = 456, ShortWord = 4, FragmentDelimiter = "&hellip;", HighlightAll = FALSE'))}
255
+ expected_sql = %{(ts_headline('simple', (coalesce(cast(#{Model.quoted_table_name}."name" AS text), '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = "<start class=""search"">", StopSel = "<stop>", MaxFragments = 3, MaxWords = 123, MinWords = 456, ShortWord = 4, FragmentDelimiter = "&hellip;", HighlightAll = FALSE'))}
256
256
 
257
257
  expect(highlight_sql).to eq(expected_sql)
258
258
  end