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
data/README.md CHANGED
@@ -13,10 +13,10 @@ Read the blog post introducing PgSearch at https://tanzu.vmware.com/content/blog
13
13
 
14
14
  ## REQUIREMENTS
15
15
 
16
- * Ruby 3.0+
17
- * Active Record 6.1+
18
- * PostgreSQL 9.2+
19
- * [PostgreSQL extensions](https://github.com/Casecommons/pg_search/wiki/Installing-PostgreSQL-Extensions) for certain features
16
+ - Ruby 3.3+
17
+ - Active Record 8.0+
18
+ - PostgreSQL 9.2+
19
+ - [PostgreSQL extensions](https://github.com/Casecommons/pg_search/wiki/Installing-PostgreSQL-Extensions) for certain features
20
20
 
21
21
  ## INSTALL
22
22
 
@@ -51,45 +51,46 @@ end
51
51
  ```
52
52
 
53
53
  ### Contents
54
- * [Multi-search vs. search scopes](#multi-search-vs-search-scopes)
55
- * [Multi-search](#multi-search)
56
- * [Setup](#setup)
57
- * [`multisearchable`](#multisearchable)
58
- * [More Options ](#more-options)
59
- * [Multi-search associations](#multi-search-associations)
60
- * [Searching in the global search index](#searching-in-the-global-search-index)
61
- * [Chaining method calls onto the results](#chaining-method-calls-onto-the-results)
62
- * [Configuring multi-search](#configuring-multi-search)
63
- * [Rebuilding search documents for a given class](#rebuilding-search-documents-for-a-given-class)
64
- * [Disabling multi-search indexing temporarily](#disabling-multi-search-indexing-temporarily)
65
- * [`pg_search_scope`](#pg_search_scope)
66
- * [Searching against one column](#searching-against-one-column)
67
- * [Searching against multiple columns](#searching-against-multiple-columns)
68
- * [Dynamic search scopes](#dynamic-search-scopes)
69
- * [Searching through associations](#searching-through-associations)
70
- * [Searching using different search features](#searching-using-different-search-features)
71
- * [`:tsearch` (Full Text Search)](#tsearch-full-text-search)
72
- * [Weighting](#weighting)
73
- * [`:prefix` (PostgreSQL 8.4 and newer only)](#prefix-postgresql-84-and-newer-only)
74
- * [`:negation`](#negation)
75
- * [`:dictionary`](#dictionary)
76
- * [`:normalization`](#normalization)
77
- * [`:any_word`](#any_word)
78
- * [`:sort_only`](#sort_only)
79
- * [`:highlight`](#highlight)
80
- * [`:dmetaphone` (Double Metaphone soundalike search)](#dmetaphone-double-metaphone-soundalike-search)
81
- * [`:trigram` (Trigram search)](#trigram-trigram-search)
82
- * [`:threshold`](#threshold)
83
- * [`:word_similarity`](#word_similarity)
84
- * [Limiting Fields When Combining Features](#limiting-fields-when-combining-features)
85
- * [Ignoring accent marks](#ignoring-accent-marks)
86
- * [Using tsvector columns](#using-tsvector-columns)
87
- * [Combining multiple tsvectors](#combining-multiple-tsvectors)
88
- * [Configuring ranking and ordering](#configuring-ranking-and-ordering)
89
- * [`:ranked_by` (Choosing a ranking algorithm)](#ranked_by-choosing-a-ranking-algorithm)
90
- * [`:order_within_rank` (Breaking ties)](#order_within_rank-breaking-ties)
91
- * [`PgSearch#pg_search_rank` (Reading a record's rank as a Float)](#pgsearchpg_search_rank-reading-a-records-rank-as-a-float)
92
- * [Search rank and chained scopes](#search-rank-and-chained-scopes)
54
+
55
+ - [Multi-search vs. search scopes](#multi-search-vs-search-scopes)
56
+ - [Multi-search](#multi-search)
57
+ - [Setup](#setup)
58
+ - [`multisearchable`](#multisearchable)
59
+ - [More Options ](#more-options)
60
+ - [Multi-search associations](#multi-search-associations)
61
+ - [Searching in the global search index](#searching-in-the-global-search-index)
62
+ - [Chaining method calls onto the results](#chaining-method-calls-onto-the-results)
63
+ - [Configuring multi-search](#configuring-multi-search)
64
+ - [Rebuilding search documents for a given class](#rebuilding-search-documents-for-a-given-class)
65
+ - [Disabling multi-search indexing temporarily](#disabling-multi-search-indexing-temporarily)
66
+ - [`pg_search_scope`](#pg_search_scope)
67
+ - [Searching against one column](#searching-against-one-column)
68
+ - [Searching against multiple columns](#searching-against-multiple-columns)
69
+ - [Dynamic search scopes](#dynamic-search-scopes)
70
+ - [Searching through associations](#searching-through-associations)
71
+ - [Searching using different search features](#searching-using-different-search-features)
72
+ - [`:tsearch` (Full Text Search)](#tsearch-full-text-search)
73
+ - [Weighting](#weighting)
74
+ - [`:prefix` (PostgreSQL 8.4 and newer only)](#prefix-postgresql-84-and-newer-only)
75
+ - [`:negation`](#negation)
76
+ - [`:dictionary`](#dictionary)
77
+ - [`:normalization`](#normalization)
78
+ - [`:any_word`](#any_word)
79
+ - [`:sort_only`](#sort_only)
80
+ - [`:highlight`](#highlight)
81
+ - [`:dmetaphone` (Double Metaphone soundalike search)](#dmetaphone-double-metaphone-soundalike-search)
82
+ - [`:trigram` (Trigram search)](#trigram-trigram-search)
83
+ - [`:threshold`](#threshold)
84
+ - [`:word_similarity`](#word_similarity)
85
+ - [Limiting Fields When Combining Features](#limiting-fields-when-combining-features)
86
+ - [Ignoring accent marks](#ignoring-accent-marks)
87
+ - [Using tsvector columns](#using-tsvector-columns)
88
+ - [Combining multiple tsvectors](#combining-multiple-tsvectors)
89
+ - [Configuring ranking and ordering](#configuring-ranking-and-ordering)
90
+ - [`:ranked_by` (Choosing a ranking algorithm)](#ranked_by-choosing-a-ranking-algorithm)
91
+ - [`:order_within_rank` (Breaking ties)](#order_within_rank-breaking-ties)
92
+ - [`PgSearch#pg_search_rank` (Reading a record's rank as a Float)](#pgsearchpg_search_rank-reading-a-records-rank-as-a-float)
93
+ - [Search rank and chained scopes](#search-rank-and-chained-scopes)
93
94
 
94
95
  ### Multi-search vs. search scopes
95
96
 
@@ -246,8 +247,8 @@ This allows much faster searches without joins later on by doing something like:
246
247
  PgSearch.multisearch(params['search']).where(author_id: 2)
247
248
  ```
248
249
 
249
- *NOTE: You must currently manually call `record.update_pg_search_document` for
250
- the additional attribute to be included in the pg_search_documents table*
250
+ _NOTE: You must currently manually call `record.update_pg_search_document` for
251
+ the additional attribute to be included in the pg_search_documents table_
251
252
 
252
253
  #### Multi-search associations
253
254
 
@@ -331,18 +332,18 @@ To regenerate the documents for a given class, run:
331
332
  PgSearch::Multisearch.rebuild(Product)
332
333
  ```
333
334
 
334
- The ```rebuild``` method will delete all the documents for the given class
335
+ The `rebuild` method will delete all the documents for the given class
335
336
  before regenerating them. In some situations this may not be desirable,
336
- such as when you're using single-table inheritance and ```searchable_type```
337
- is your base class. You can prevent ```rebuild``` from deleting your records
337
+ such as when you're using single-table inheritance and `searchable_type`
338
+ is your base class. You can prevent `rebuild` from deleting your records
338
339
  like so:
339
340
 
340
341
  ```ruby
341
342
  PgSearch::Multisearch.rebuild(Product, clean_up: false)
342
343
  ```
343
344
 
344
- ```rebuild``` runs inside a single transaction. To run outside of a transaction,
345
- you can pass ```transactional: false``` like so:
345
+ `rebuild` runs inside a single transaction. To run outside of a transaction,
346
+ you can pass `transactional: false` like so:
346
347
 
347
348
  ```ruby
348
349
  PgSearch::Multisearch.rebuild(Product, transactional: false)
@@ -399,7 +400,8 @@ class Movie < ActiveRecord::Base
399
400
  end
400
401
  end
401
402
  ```
402
- **Note:** If using PostgreSQL before 9.1, replace the `CONCAT_WS()` function call with double-pipe concatenation, eg. `(movies.name || ' ' || directors.name)`. However, now be aware that if *any* of the joined values is NULL then the final `content` value will also be NULL, whereas `CONCAT_WS()` will selectively ignore NULL values.
403
+
404
+ **Note:** If using PostgreSQL before 9.1, replace the `CONCAT_WS()` function call with double-pipe concatenation, eg. `(movies.name || ' ' || directors.name)`. However, now be aware that if _any_ of the joined values is NULL then the final `content` value will also be NULL, whereas `CONCAT_WS()` will selectively ignore NULL values.
403
405
 
404
406
  #### Disabling multi-search indexing temporarily
405
407
 
@@ -569,11 +571,10 @@ end
569
571
 
570
572
  The currently implemented features are
571
573
 
572
- * :tsearch - [Full text search](http://www.postgresql.org/docs/current/static/textsearch-intro.html), which is built-in to PostgreSQL
573
- * :trigram - [Trigram search](http://www.postgresql.org/docs/current/static/pgtrgm.html), which
574
- requires the trigram extension
575
- * :dmetaphone - [Double Metaphone search](http://www.postgresql.org/docs/current/static/fuzzystrmatch.html#AEN177521), which requires the fuzzystrmatch extension
576
-
574
+ - :tsearch - [Full text search](http://www.postgresql.org/docs/current/static/textsearch-intro.html), which is built-in to PostgreSQL
575
+ - :trigram - [Trigram search](http://www.postgresql.org/docs/current/static/pgtrgm.html), which
576
+ requires the trigram extension
577
+ - :dmetaphone - [Double Metaphone search](http://www.postgresql.org/docs/current/static/fuzzystrmatch.html#AEN177521), which requires the fuzzystrmatch extension
577
578
 
578
579
  #### :tsearch (Full Text Search)
579
580
 
@@ -581,6 +582,7 @@ PostgreSQL's built-in full text search supports weighting, prefix searches,
581
582
  and stemming in multiple languages.
582
583
 
583
584
  ##### Weighting
585
+
584
586
  Each searchable column can be given a weight of "A", "B", "C", or "D". Columns
585
587
  with earlier letters are weighted higher than those with later letters. So, in
586
588
  the following example, the title is the most important, followed by the
@@ -644,6 +646,7 @@ robin = Superhero.create name: 'Robin'
644
646
 
645
647
  Superhero.whose_name_starts_with("Bat") # => [batman, batgirl]
646
648
  ```
649
+
647
650
  ##### :negation
648
651
 
649
652
  PostgreSQL's full text search matches all search terms by default. If you want
@@ -804,7 +807,6 @@ Person.search('ash hines') # => [exact, one_exact_one_close, one_exact]
804
807
  Adding .with_pg_search_highlight after the pg_search_scope you can access to
805
808
  `pg_highlight` attribute for each object.
806
809
 
807
-
808
810
  ```ruby
809
811
  class Person < ActiveRecord::Base
810
812
  include PgSearch::Model
@@ -815,8 +817,8 @@ class Person < ActiveRecord::Base
815
817
  highlight: {
816
818
  StartSel: '<b>',
817
819
  StopSel: '</b>',
818
- MaxWords: 123,
819
- MinWords: 456,
820
+ MinWords: 123,
821
+ MaxWords: 456,
820
822
  ShortWord: 4,
821
823
  HighlightAll: true,
822
824
  MaxFragments: 3,
@@ -1053,31 +1055,31 @@ as it offloads creation of the tsvector that the tsquery is evaluated against.
1053
1055
 
1054
1056
  To use this functionality you'll need to do a few things:
1055
1057
 
1056
- * Create a column of type tsvector that you'd like to search against. If you
1057
- want to search using multiple search methods, for example tsearch and
1058
- dmetaphone, you'll need a column for each.
1059
- * Create a trigger function that will update the column(s) using the
1060
- expression appropriate for that type of search. See:
1061
- [the PostgreSQL documentation for text search triggers](http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS)
1062
- * Should you have any pre-existing data in the table, update the
1063
- newly-created tsvector columns with the expression that your trigger
1064
- function uses.
1065
- * Add the option to pg_search_scope, e.g:
1066
-
1067
- ```ruby
1068
- pg_search_scope :fast_content_search,
1069
- against: :content,
1070
- using: {
1071
- dmetaphone: {
1072
- tsvector_column: 'tsvector_content_dmetaphone'
1073
- },
1074
- tsearch: {
1075
- dictionary: 'english',
1076
- tsvector_column: 'tsvector_content_tsearch'
1077
- },
1078
- trigram: {} # trigram does not use tsvectors
1079
- }
1080
- ```
1058
+ - Create a column of type tsvector that you'd like to search against. If you
1059
+ want to search using multiple search methods, for example tsearch and
1060
+ dmetaphone, you'll need a column for each.
1061
+ - Create a trigger function that will update the column(s) using the
1062
+ expression appropriate for that type of search. See:
1063
+ [the PostgreSQL documentation for text search triggers](http://www.postgresql.org/docs/current/static/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS)
1064
+ - Should you have any pre-existing data in the table, update the
1065
+ newly-created tsvector columns with the expression that your trigger
1066
+ function uses.
1067
+ - Add the option to pg_search_scope, e.g:
1068
+
1069
+ ```ruby
1070
+ pg_search_scope :fast_content_search,
1071
+ against: :content,
1072
+ using: {
1073
+ dmetaphone: {
1074
+ tsvector_column: 'tsvector_content_dmetaphone'
1075
+ },
1076
+ tsearch: {
1077
+ dictionary: 'english',
1078
+ tsvector_column: 'tsvector_content_tsearch'
1079
+ },
1080
+ trigram: {} # trigram does not use tsvectors
1081
+ }
1082
+ ```
1081
1083
 
1082
1084
  Please note that the :against column is only used when the tsvector_column is
1083
1085
  not present for the search type.
@@ -1190,9 +1192,9 @@ shirt_brands[1].pg_search_rank #=> 0.0607927
1190
1192
 
1191
1193
  #### Search rank and chained scopes
1192
1194
 
1193
- Each PgSearch scope generates a named subquery for the search rank. If you
1195
+ Each PgSearch scope generates a named subquery for the search rank. If you
1194
1196
  chain multiple scopes then PgSearch will generate a ranking query for each
1195
- scope, so the ranking queries must have unique names. If you need to reference
1197
+ scope, so the ranking queries must have unique names. If you need to reference
1196
1198
  the ranking query (e.g. in a GROUP BY clause) you can regenerate the subquery
1197
1199
  name with the `PgScope::Configuration.alias` method by passing the name of the
1198
1200
  queried table.
@@ -1218,5 +1220,5 @@ for discussing pg_search and other Casebook PBC open source projects.
1218
1220
 
1219
1221
  ## LICENSE
1220
1222
 
1221
- Copyright © 2010–2022 [Casebook PBC](http://www.casebook.net).
1223
+ Copyright © 2010–2026 [Casebook PBC](http://www.casebook.net).
1222
1224
  Licensed under the MIT license, see [LICENSE](/LICENSE) file.
data/Rakefile CHANGED
@@ -8,10 +8,4 @@ RSpec::Core::RakeTask.new(:spec)
8
8
 
9
9
  require "standard/rake"
10
10
 
11
- desc "Check test coverage"
12
- task :undercover do
13
- system("git fetch --unshallow") if ENV["CI"]
14
- exit(1) unless system("bin/undercover --compare origin/master")
15
- end
16
-
17
- task default: %w[spec standard undercover]
11
+ task default: %w[spec standard]
@@ -0,0 +1,43 @@
1
+ # Incremental Arel migration
2
+
3
+ Replace useful SQL-string construction with composable Arel expressions in five
4
+ small, independently mergeable changes. Complete elimination of interpolation is
5
+ not required. Each change includes its own caller adaptations and tests; no later
6
+ change should be needed to make an earlier one work.
7
+
8
+ Use `to_arel` for expressions and `to_sql` for rendered SQL strings. Keep behavior
9
+ unchanged, including trusted SQL expressions and custom ordering. Preserve the
10
+ existing rank-join alias handling. Prefer named immutable values (`Data.define`)
11
+ for structured records, while retaining arrays for genuine expression lists.
12
+
13
+ ## Review and test each layer
14
+
15
+ Before changing code, assess existing coverage and characterize the relevant
16
+ behavior. Demonstrate failing coverage for new contracts or missing regressions,
17
+ then implement the narrow change. Test SQL execution against PostgreSQL, not just
18
+ rendered strings. Run focused examples, the full suite, and Standard for every
19
+ layer. Review correctness, coverage quality, and unnecessary complexity before
20
+ publishing it.
21
+
22
+ Exercise expression behavior through `to_arel`. Keep `to_sql` coverage focused on
23
+ its rendering-adapter contract, so eventually removing it does not discard the
24
+ behavioral coverage or require duplicating that coverage across both interfaces.
25
+
26
+ - [x] **Column expressions:** add explicit Arel conversions for normal and foreign
27
+ columns, preserving string conversions. Cover NULLs, casts, quoted identifiers,
28
+ and trusted JSON expressions.
29
+ - [x] **Association queries:** build aggregated projections and joins with Arel.
30
+ Cover association types, missing associations, aggregation, and alias collisions.
31
+ - [x] **Rank selection and ordering:** convert projections and ordering without
32
+ replacing the existing rank-join fix. Cover selected ranks, tie-breaking,
33
+ custom ordering, associations, and chained scopes.
34
+ - [x] **Feature expressions and normalization:** compose search expressions as
35
+ nodes while preserving sanitization. Cover blank queries, quotes and accents,
36
+ prefix/negation, weighted and stored vectors, highlighting, and other features.
37
+ - [x] **Multisearch rebuilds:** convert useful statement components without forcing
38
+ every fixed SQL fragment into Arel. Cover actual rebuilds, STI, NULL content,
39
+ multiple columns, and custom identifiers.
40
+
41
+ Record remaining interpolation after the stack as deferred work, not a release
42
+ gate. Keep unrelated cleanup, historical planning artifacts, and obsolete
43
+ compatibility code out of these changes.
@@ -0,0 +1,48 @@
1
+ # Remaining Arel expression construction
2
+
3
+ Continuation of the approved [Arel stack](2026-09-05-arel-stack.md), authorized
4
+ for autonomous follow-through while the earlier PRs are reviewed. Preserve
5
+ those PR heads; publish new independently green layers above #587.
6
+
7
+ ## Contract
8
+
9
+ Replace remaining SQL-construction interpolation and avoidable raw SQL fragments
10
+ with composable expressions, without changing query behavior or existing
11
+ String-returning adapter contracts.
12
+ Do not rewrite ordinary messages, generated names, or PostgreSQL headline-option
13
+ data merely to eliminate Ruby interpolation. No new database functions/migrations.
14
+
15
+ - [x] **Query projections and vectors:** convert ScopeOptions star/highlight/rank
16
+ projections and qualified primary-key rendering, plus TSearch's stored-vector
17
+ references. Preserve explicit select behavior, trusted custom SQL, chained
18
+ aliases, quoted identifiers, and synthetic rank references without accidentally
19
+ applying model attribute aliases.
20
+ - [x] **Column source references:** replace full_name interpolation and the
21
+ aggregate's String-to-Arel round trip with a coherent source-attribute
22
+ expression. Preserve the distinction between a foreign column's original
23
+ association source and its derived search-document alias, non-coalesced
24
+ aggregation, and the existing full_name/to_sql String interfaces.
25
+ - [x] **Quoted values:** replace hand-quoted data and quote/render/wrap patterns
26
+ with quoted-value nodes: separators, empty strings, regex arguments, model
27
+ names, and timestamps. Preserve actual data, escaping, NULL behavior, and the
28
+ shared timestamp. Do not confuse a SQL type token or empty SQL fragment with
29
+ a string value.
30
+ - [x] **Expression boundaries:** preserve Arel attributes through normalization
31
+ instead of coercing their Ruby inspection into SQL; remove avoidable internal
32
+ render-and-wrap cycles for primary keys and default ranking. Preserve the
33
+ documented custom SQL inputs and legacy String adapters at their boundaries.
34
+ Investigate empty-expression fragments before changing their semantics.
35
+ - [x] **Final audit:** verify remaining interpolation/raw SQL is intentional
36
+ syntax, data serialization, or trusted escape-hatch input; report residue
37
+ rather than hiding it.
38
+
39
+ ## Verification and delivery
40
+
41
+ Characterize meaningful missing behavior before refactoring; no private-helper
42
+ specs or test-only visibility subclasses. Simplify touched fixture setup using
43
+ normal schema names and Rails conventions. Prefer public database behavior over
44
+ additional SQL golden matrices. Run relevant specs and bin/rake independently
45
+ for each layer, including the existing pinned Rails-main environment; require
46
+ one focused independent review per layer and green hosted CI.
47
+
48
+ Check each item with its owning code.
@@ -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
@@ -23,9 +23,7 @@ module PgSearch
23
23
  end
24
24
  end
25
25
 
26
- def columns
27
- regular_columns + associated_columns
28
- end
26
+ def columns = regular_columns + associated_columns
29
27
 
30
28
  def regular_columns
31
29
  return [] unless options[:against]
@@ -43,37 +41,21 @@ module PgSearch
43
41
  end.flatten
44
42
  end
45
43
 
46
- def associated_columns
47
- associations.map(&:columns).flatten
48
- end
44
+ def associated_columns = associations.map(&:columns).flatten
49
45
 
50
- def query
51
- options[:query].to_s
52
- end
46
+ def query = options[:query].to_s
53
47
 
54
- def ignore
55
- Array(options[:ignoring])
56
- end
48
+ def ignore = Array(options[:ignoring])
57
49
 
58
- def ranking_sql
59
- options[:ranked_by]
60
- end
50
+ def ranking_sql = options[:ranked_by]
61
51
 
62
- def features
63
- Array(options[:using])
64
- end
52
+ def features = Array(options[:using])
65
53
 
66
54
  def feature_options
67
- @feature_options ||= {}.tap do |hash|
68
- features.map do |feature_name, feature_options|
69
- hash[feature_name] = feature_options
70
- end
71
- end
55
+ @feature_options ||= features.to_h { |name, opts| [name, opts] }
72
56
  end
73
57
 
74
- def order_within_rank
75
- options[:order_within_rank]
76
- end
58
+ def order_within_rank = options[:order_within_rank]
77
59
 
78
60
  private
79
61
 
@@ -83,6 +65,7 @@ module PgSearch
83
65
  {using: :tsearch}
84
66
  end
85
67
 
68
+ # standard:disable Lint/UselessConstantScoping
86
69
  VALID_KEYS = %w[
87
70
  against ranked_by ignoring using query associated_against order_within_rank
88
71
  ].map(&:to_sym)
@@ -90,6 +73,7 @@ module PgSearch
90
73
  VALID_VALUES = {
91
74
  ignoring: [:accents]
92
75
  }.freeze
76
+ # standard:enable Lint/UselessConstantScoping
93
77
 
94
78
  def assert_valid_options(options)
95
79
  unless options[:against] || options[:associated_against] || using_tsvector_column?(options[:using])
@@ -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