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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +0 -6
- data/.github/workflows/codeql.yml +0 -1
- data/.gitignore +0 -1
- data/.standard.yml +1 -1
- data/CHANGELOG.md +70 -65
- data/CONTRIBUTING.md +1 -1
- data/LICENSE +1 -1
- data/README.md +2 -2
- data/doc/plans/2026-09-05-arel-stack.md +43 -0
- data/doc/plans/2026-09-06-arel-remaining-expressions.md +48 -0
- data/lib/pg_search/configuration/association.rb +37 -23
- data/lib/pg_search/configuration/column.rb +17 -12
- data/lib/pg_search/configuration/foreign_column.rb +3 -5
- data/lib/pg_search/features/dmetaphone.rb +3 -7
- data/lib/pg_search/features/feature.rb +8 -4
- data/lib/pg_search/features/trigram.rb +6 -20
- data/lib/pg_search/features/tsearch.rb +30 -36
- data/lib/pg_search/features.rb +2 -0
- data/lib/pg_search/migration/generator.rb +1 -5
- data/lib/pg_search/multisearch/rebuilder.rb +69 -65
- data/lib/pg_search/normalizer.rb +37 -18
- data/lib/pg_search/scope_options.rb +28 -52
- data/lib/pg_search/version.rb +1 -1
- data/pg_search.gemspec +4 -4
- data/spec/integration/associations_spec.rb +121 -0
- data/spec/integration/pg_search_spec.rb +48 -0
- data/spec/lib/pg_search/configuration/association_spec.rb +32 -58
- data/spec/lib/pg_search/configuration/column_spec.rb +73 -15
- data/spec/lib/pg_search/configuration/foreign_column_spec.rb +52 -20
- data/spec/lib/pg_search/features/dmetaphone_spec.rb +2 -2
- data/spec/lib/pg_search/features/trigram_spec.rb +3 -3
- data/spec/lib/pg_search/features/tsearch_spec.rb +8 -8
- data/spec/lib/pg_search/multisearch/rebuilder_spec.rb +219 -229
- data/spec/lib/pg_search/multisearch_spec.rb +16 -16
- data/spec/lib/pg_search/normalizer_spec.rb +64 -11
- data/spec/lib/pg_search_spec.rb +2 -21
- 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
|
|
23
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
Arel::Attributes::Attribute.new(source_table, @name)
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
31
|
-
@
|
|
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
|
|
35
|
-
|
|
37
|
+
def cast_to_text(node)
|
|
38
|
+
Arel::Nodes::NamedFunction.new("cast", [node.as(Arel.sql("text"))])
|
|
36
39
|
end
|
|
37
40
|
|
|
38
|
-
def
|
|
39
|
-
|
|
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
|
|
22
|
-
|
|
21
|
+
def attribute
|
|
22
|
+
@model.arel_table.alias(@association.subselect_alias)[self.alias]
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def
|
|
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(
|
|
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
|
-
[
|
|
36
|
-
)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
50
|
+
Arel::Nodes::Grouping.new(normalize(document))
|
|
64
51
|
end
|
|
65
52
|
|
|
66
53
|
def normalized_query
|
|
67
|
-
|
|
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("@@",
|
|
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
|
-
|
|
20
|
+
Arel::Nodes::Grouping.new(tsearch_rank)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def highlight
|
|
24
|
-
|
|
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
|
-
|
|
33
|
-
|
|
32
|
+
Arel::Nodes::Grouping.new(document),
|
|
33
|
+
Arel::Nodes::Grouping.new(tsquery),
|
|
34
34
|
Arel::Nodes.build_quoted(ts_headline_options)
|
|
35
|
-
])
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 "
|
|
124
|
+
return Arel::Nodes.build_quoted("") if query.blank?
|
|
127
125
|
|
|
128
126
|
query_terms = query.split.compact
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
161
|
+
Arel::Nodes::Grouping.new(tsdocument),
|
|
162
|
+
Arel::Nodes::Grouping.new(tsquery),
|
|
168
163
|
normalization
|
|
169
|
-
])
|
|
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,
|
|
192
|
-
)
|
|
182
|
+
[dictionary, normalize(search_column.to_arel)]
|
|
183
|
+
)
|
|
193
184
|
|
|
194
185
|
if search_column.weight.nil?
|
|
195
186
|
tsvector
|
|
196
187
|
else
|
|
197
|
-
|
|
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
|
data/lib/pg_search/features.rb
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
80
|
-
|
|
81
|
-
end
|
|
102
|
+
def apply_sti_condition(select_manager, src_table)
|
|
103
|
+
return unless model.column_names.include?(model.inheritance_column)
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
110
|
-
connection.quote(connection.quoted_date(@time_source.call))
|
|
114
|
+
select_manager.where(condition)
|
|
111
115
|
end
|
|
112
116
|
end
|
|
113
117
|
end
|
data/lib/pg_search/normalizer.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
)
|
|
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
|