noiseless 0.0.0 → 0.2.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/LICENSE.txt +28 -0
- data/README.md +214 -0
- data/lib/application_search.rb +15 -0
- data/lib/noiseless/adapter.rb +339 -0
- data/lib/noiseless/adapters/cluster_api.rb +18 -0
- data/lib/noiseless/adapters/elasticsearch.rb +30 -0
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +68 -0
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +83 -0
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +83 -0
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +209 -0
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +425 -0
- data/lib/noiseless/adapters/indices_api.rb +26 -0
- data/lib/noiseless/adapters/open_search.rb +168 -0
- data/lib/noiseless/adapters/postgresql.rb +171 -0
- data/lib/noiseless/adapters/typesense.rb +36 -0
- data/lib/noiseless/adapters.rb +14 -0
- data/lib/noiseless/ast/aggregation.rb +56 -0
- data/lib/noiseless/ast/bool.rb +16 -0
- data/lib/noiseless/ast/bulk.rb +18 -0
- data/lib/noiseless/ast/collapse.rb +16 -0
- data/lib/noiseless/ast/combined_fields.rb +33 -0
- data/lib/noiseless/ast/conversation.rb +29 -0
- data/lib/noiseless/ast/field_value_node.rb +16 -0
- data/lib/noiseless/ast/filter.rb +8 -0
- data/lib/noiseless/ast/hybrid.rb +35 -0
- data/lib/noiseless/ast/image_query.rb +29 -0
- data/lib/noiseless/ast/join.rb +31 -0
- data/lib/noiseless/ast/match.rb +8 -0
- data/lib/noiseless/ast/multi_match.rb +24 -0
- data/lib/noiseless/ast/paginate.rb +15 -0
- data/lib/noiseless/ast/prefix.rb +8 -0
- data/lib/noiseless/ast/range.rb +18 -0
- data/lib/noiseless/ast/root.rb +69 -0
- data/lib/noiseless/ast/search_after.rb +14 -0
- data/lib/noiseless/ast/sort.rb +15 -0
- data/lib/noiseless/ast/vector.rb +27 -0
- data/lib/noiseless/ast/wildcard.rb +8 -0
- data/lib/noiseless/ast.rb +30 -0
- data/lib/noiseless/bulk_importer.rb +195 -0
- data/lib/noiseless/callbacks.rb +138 -0
- data/lib/noiseless/connection_manager.rb +26 -0
- data/lib/noiseless/document_manager.rb +137 -0
- data/lib/noiseless/dsl.rb +107 -0
- data/lib/noiseless/generators/application_search_generator.rb +24 -0
- data/lib/noiseless/instrumentation.rb +174 -0
- data/lib/noiseless/introspection/console.rb +228 -0
- data/lib/noiseless/introspection/query_visualizer.rb +533 -0
- data/lib/noiseless/introspection.rb +221 -0
- data/lib/noiseless/mapping.rb +253 -0
- data/lib/noiseless/mapping_definition_processor.rb +231 -0
- data/lib/noiseless/model.rb +111 -0
- data/lib/noiseless/model_registry.rb +77 -0
- data/lib/noiseless/multi_search.rb +244 -0
- data/lib/noiseless/pagination.rb +375 -0
- data/lib/noiseless/query_builder.rb +284 -0
- data/lib/noiseless/railtie.rb +35 -0
- data/lib/noiseless/response/aggregations.rb +46 -0
- data/lib/noiseless/response/empty.rb +20 -0
- data/lib/noiseless/response/records.rb +94 -0
- data/lib/noiseless/response/results.rb +110 -0
- data/lib/noiseless/response/suggestions.rb +55 -0
- data/lib/noiseless/response.rb +98 -0
- data/lib/noiseless/response_factory.rb +32 -0
- data/lib/noiseless/runtime_reset_middleware.rb +15 -0
- data/lib/noiseless/search_index_update_job.rb +84 -0
- data/lib/noiseless/test_case.rb +230 -0
- data/lib/noiseless/test_helper.rb +295 -0
- data/lib/noiseless/version.rb +2 -2
- data/lib/noiseless.rb +146 -2
- data/lib/tasks/benchmark.rake +35 -0
- data/lib/tasks/release.rake +22 -0
- data/lib/tasks/test.rake +11 -0
- metadata +265 -14
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
module AST
|
|
5
|
+
# Vector search node for semantic/embedding-based search
|
|
6
|
+
# Used with pgvector in PostgreSQL or knn in OpenSearch
|
|
7
|
+
class Vector < Node
|
|
8
|
+
attr_reader :field, :embedding, :k, :distance_metric
|
|
9
|
+
|
|
10
|
+
# @param field [Symbol, String] The embedding column/field
|
|
11
|
+
# @param embedding [Array<Float>] The query embedding vector
|
|
12
|
+
# @param k [Integer] Number of nearest neighbors (default: 10)
|
|
13
|
+
# @param distance_metric [Symbol] :cosine, :l2, or :inner_product (default: :cosine)
|
|
14
|
+
def initialize(field, embedding, k: 10, distance_metric: :cosine)
|
|
15
|
+
super()
|
|
16
|
+
@field = field
|
|
17
|
+
@embedding = embedding
|
|
18
|
+
@k = k
|
|
19
|
+
@distance_metric = distance_metric
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def dimension
|
|
23
|
+
@embedding&.size || 0
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
module AST
|
|
5
|
+
class Node
|
|
6
|
+
def to_h
|
|
7
|
+
hash = {}
|
|
8
|
+
instance_variables.each do |var|
|
|
9
|
+
key = var.to_s.delete("@").to_sym
|
|
10
|
+
value = instance_variable_get(var)
|
|
11
|
+
|
|
12
|
+
hash[key] = case value
|
|
13
|
+
when Node
|
|
14
|
+
value.to_h
|
|
15
|
+
when Array
|
|
16
|
+
value.map { |item| item.is_a?(Node) ? item.to_h : item }
|
|
17
|
+
else
|
|
18
|
+
value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Include the class name for better introspection
|
|
23
|
+
hash[:_type] = self.class.name.split("::").last
|
|
24
|
+
hash
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
alias to_hash to_h
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
class BulkImporter
|
|
5
|
+
attr_reader :model_class, :errors
|
|
6
|
+
|
|
7
|
+
def initialize(model_class, connection: nil)
|
|
8
|
+
@model_class = model_class
|
|
9
|
+
@connection = connection || model_class.connection
|
|
10
|
+
@errors = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def import(relation_or_records = nil,
|
|
14
|
+
batch_size: 1000,
|
|
15
|
+
transform: nil,
|
|
16
|
+
preprocess: nil,
|
|
17
|
+
force: false,
|
|
18
|
+
refresh: true,
|
|
19
|
+
**)
|
|
20
|
+
@errors.clear
|
|
21
|
+
|
|
22
|
+
# Create index if force is true
|
|
23
|
+
if force
|
|
24
|
+
delete_index
|
|
25
|
+
create_index
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get records to import
|
|
29
|
+
records = resolve_records(relation_or_records)
|
|
30
|
+
|
|
31
|
+
total_imported = 0
|
|
32
|
+
|
|
33
|
+
records.each_slice(batch_size) do |batch|
|
|
34
|
+
# Apply preprocessing to the entire batch
|
|
35
|
+
processed_batch = preprocess ? preprocess.call(batch) : batch
|
|
36
|
+
|
|
37
|
+
# Transform individual records and build actions
|
|
38
|
+
actions = build_bulk_actions(processed_batch, transform)
|
|
39
|
+
|
|
40
|
+
# Execute bulk operation
|
|
41
|
+
begin
|
|
42
|
+
client = Noiseless.connections.client(@connection)
|
|
43
|
+
response = client.bulk(actions, refresh: refresh, **)
|
|
44
|
+
|
|
45
|
+
# Check for errors in response
|
|
46
|
+
collect_errors(response, processed_batch)
|
|
47
|
+
|
|
48
|
+
total_imported += actions.size
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
@errors << {
|
|
51
|
+
error: e.message,
|
|
52
|
+
batch: processed_batch.map { |r| identify_record(r) }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
imported: total_imported,
|
|
59
|
+
errors: @errors.size,
|
|
60
|
+
error_details: @errors
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def import_scoped(scope, **)
|
|
65
|
+
import(scope, **)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reindex(batch_size: 1000, **)
|
|
69
|
+
raise ArgumentError, "Model class #{model_class} must respond to :all for reindexing" unless model_class.respond_to?(:all)
|
|
70
|
+
|
|
71
|
+
import(model_class.all, batch_size: batch_size, force: true, **)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def resolve_records(relation_or_records)
|
|
77
|
+
case relation_or_records
|
|
78
|
+
when nil
|
|
79
|
+
model_class.respond_to?(:all) ? model_class.all : []
|
|
80
|
+
when String, Symbol
|
|
81
|
+
# Assume it's a scope name
|
|
82
|
+
if model_class.respond_to?(relation_or_records)
|
|
83
|
+
model_class.public_send(relation_or_records)
|
|
84
|
+
else
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
relation_or_records
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_bulk_actions(batch, transform)
|
|
93
|
+
batch.filter_map do |record|
|
|
94
|
+
# Apply transform function if provided
|
|
95
|
+
document = transform ? transform.call(record) : default_transform(record)
|
|
96
|
+
next unless document
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
index: {
|
|
100
|
+
_index: index_name,
|
|
101
|
+
_id: extract_id(record),
|
|
102
|
+
data: document
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
@errors << {
|
|
107
|
+
error: e.message,
|
|
108
|
+
record: identify_record(record)
|
|
109
|
+
}
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def default_transform(record)
|
|
115
|
+
if record.respond_to?(:to_h)
|
|
116
|
+
record.to_h
|
|
117
|
+
elsif record.respond_to?(:attributes)
|
|
118
|
+
record.attributes
|
|
119
|
+
else
|
|
120
|
+
record
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_id(record)
|
|
125
|
+
if record.respond_to?(:id)
|
|
126
|
+
record.id
|
|
127
|
+
elsif record.is_a?(Hash)
|
|
128
|
+
record[:id] || record["id"]
|
|
129
|
+
else
|
|
130
|
+
record.object_id
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def identify_record(record)
|
|
135
|
+
id = extract_id(record)
|
|
136
|
+
{
|
|
137
|
+
id: id,
|
|
138
|
+
class: record.class.name,
|
|
139
|
+
object_id: record.object_id
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def collect_errors(response, batch)
|
|
144
|
+
return unless response.is_a?(Hash) && response["items"]
|
|
145
|
+
|
|
146
|
+
response["items"].each_with_index do |item, index|
|
|
147
|
+
action = item.keys.first
|
|
148
|
+
result = item[action]
|
|
149
|
+
|
|
150
|
+
next unless result["error"]
|
|
151
|
+
|
|
152
|
+
record = batch[index]
|
|
153
|
+
@errors << {
|
|
154
|
+
error: result["error"],
|
|
155
|
+
record: identify_record(record),
|
|
156
|
+
status: result["status"]
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def index_name
|
|
162
|
+
@index_name ||= if model_class.respond_to?(:search_index)
|
|
163
|
+
Array(model_class.search_index).first
|
|
164
|
+
else
|
|
165
|
+
model_class.name.demodulize.underscore.pluralize
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete_index
|
|
170
|
+
client = Noiseless.connections.client(@connection)
|
|
171
|
+
client.delete_index(index_name)
|
|
172
|
+
rescue StandardError => _e
|
|
173
|
+
# Index might not exist, which is fine
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def create_index
|
|
178
|
+
return unless model_class.respond_to?(:mapping)
|
|
179
|
+
|
|
180
|
+
mapping_block = model_class.mapping
|
|
181
|
+
return unless mapping_block
|
|
182
|
+
|
|
183
|
+
begin
|
|
184
|
+
_client = Noiseless.connections.client(@connection)
|
|
185
|
+
# This would need to be implemented in the adapter
|
|
186
|
+
# client.create_index(index_name, mapping: mapping_block)
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
@errors << {
|
|
189
|
+
error: "Failed to create index: #{e.message}",
|
|
190
|
+
index: index_name
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Noiseless
|
|
6
|
+
module Callbacks
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
after_save :update_search_index_on_save
|
|
11
|
+
after_destroy :remove_from_search_index
|
|
12
|
+
after_commit :update_search_index_on_commit, on: %i[create update]
|
|
13
|
+
after_commit :remove_from_search_index_on_commit, on: :destroy
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def auto_index(enabled: true, **options)
|
|
18
|
+
@auto_index_enabled = enabled
|
|
19
|
+
@auto_index_options = options
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def auto_index_enabled?
|
|
23
|
+
@auto_index_enabled != false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def auto_index_options
|
|
27
|
+
@auto_index_options ||= {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def skip_auto_index
|
|
31
|
+
previous_value = Thread.current[:noiseless_skip_auto_index]
|
|
32
|
+
Thread.current[:noiseless_skip_auto_index] = true
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
Thread.current[:noiseless_skip_auto_index] = previous_value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def should_update_search_index?
|
|
42
|
+
return false if Thread.current[:noiseless_skip_auto_index]
|
|
43
|
+
return false unless self.class.auto_index_enabled?
|
|
44
|
+
|
|
45
|
+
# Only update if we have searchable content
|
|
46
|
+
if respond_to?(:searchable?)
|
|
47
|
+
searchable?
|
|
48
|
+
else
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def update_search_index_on_save
|
|
54
|
+
return unless should_update_search_index?
|
|
55
|
+
|
|
56
|
+
update_search_index_async if noiseless_new_record? || (respond_to?(:changed?) && changed?)
|
|
57
|
+
rescue Net::ProtocolError, JSON::ParserError, Timeout::Error => e
|
|
58
|
+
handle_search_index_error(e, :update)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def update_search_index_on_commit
|
|
62
|
+
return unless should_update_search_index?
|
|
63
|
+
|
|
64
|
+
update_search_index_async
|
|
65
|
+
rescue Net::ProtocolError, JSON::ParserError, Timeout::Error => e
|
|
66
|
+
handle_search_index_error(e, :update)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def remove_from_search_index
|
|
70
|
+
return unless should_update_search_index?
|
|
71
|
+
|
|
72
|
+
remove_from_search_index_async
|
|
73
|
+
rescue Net::ProtocolError, JSON::ParserError, Timeout::Error => e
|
|
74
|
+
handle_search_index_error(e, :delete)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remove_from_search_index_on_commit
|
|
78
|
+
return unless should_update_search_index?
|
|
79
|
+
|
|
80
|
+
remove_from_search_index_async
|
|
81
|
+
rescue Net::ProtocolError, JSON::ParserError, Timeout::Error => e
|
|
82
|
+
handle_search_index_error(e, :delete)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_search_index_async
|
|
86
|
+
options = self.class.auto_index_options
|
|
87
|
+
|
|
88
|
+
if options[:async]
|
|
89
|
+
# Queue for background processing
|
|
90
|
+
SearchIndexUpdateJob.perform_later(
|
|
91
|
+
self.class.name,
|
|
92
|
+
id,
|
|
93
|
+
"update",
|
|
94
|
+
options
|
|
95
|
+
)
|
|
96
|
+
else
|
|
97
|
+
# Immediate update
|
|
98
|
+
document_manager.update_document(**options)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def remove_from_search_index_async
|
|
103
|
+
options = self.class.auto_index_options
|
|
104
|
+
|
|
105
|
+
if options[:async]
|
|
106
|
+
# Queue for background processing
|
|
107
|
+
SearchIndexUpdateJob.perform_later(
|
|
108
|
+
self.class.name,
|
|
109
|
+
id,
|
|
110
|
+
"delete",
|
|
111
|
+
options
|
|
112
|
+
)
|
|
113
|
+
else
|
|
114
|
+
# Immediate removal
|
|
115
|
+
document_manager.delete_document(**options)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_search_index_error(error, operation)
|
|
120
|
+
options = self.class.auto_index_options
|
|
121
|
+
|
|
122
|
+
if options[:raise_on_error]
|
|
123
|
+
raise error
|
|
124
|
+
elsif (logger = Rails.logger)
|
|
125
|
+
# Log the error or handle silently based on configuration
|
|
126
|
+
logger.error "Noiseless: Failed to #{operation} search index for #{self.class.name}##{id}: #{error.message}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def noiseless_new_record?
|
|
131
|
+
if respond_to?(:persisted?)
|
|
132
|
+
!persisted?
|
|
133
|
+
else
|
|
134
|
+
!id
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
class ConnectionManager
|
|
5
|
+
def initialize
|
|
6
|
+
@clients = {}
|
|
7
|
+
@configs = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Register a named client statically from YAML (boot-time only)
|
|
11
|
+
def register(name, adapter:, hosts:)
|
|
12
|
+
@configs[name.to_sym] = { adapter: adapter, hosts: hosts }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Retrieve a client; defaults to :primary
|
|
16
|
+
def client(name = :primary)
|
|
17
|
+
name = name.to_sym
|
|
18
|
+
|
|
19
|
+
# Lazy-load the adapter only when actually used
|
|
20
|
+
@clients[name] ||= begin
|
|
21
|
+
config = @configs.fetch(name) { raise "Unknown connection: #{name}" }
|
|
22
|
+
Adapters.lookup(config[:adapter], hosts: config[:hosts])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
class DocumentManager
|
|
5
|
+
def initialize(model_instance, connection: nil)
|
|
6
|
+
@model_instance = model_instance
|
|
7
|
+
@connection = connection || model_instance.class.connection
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def index_document(refresh: false, **)
|
|
11
|
+
document = build_document
|
|
12
|
+
return false unless document
|
|
13
|
+
|
|
14
|
+
client = Noiseless.connections.client(@connection)
|
|
15
|
+
client.index_document(
|
|
16
|
+
index: index_name,
|
|
17
|
+
id: document_id,
|
|
18
|
+
document: document,
|
|
19
|
+
refresh: refresh,
|
|
20
|
+
**
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update_document(refresh: false, detect_changes: true, **)
|
|
25
|
+
if detect_changes && supports_dirty_tracking?
|
|
26
|
+
return false unless has_changes?
|
|
27
|
+
|
|
28
|
+
changes = extract_changes
|
|
29
|
+
return false if changes.empty?
|
|
30
|
+
|
|
31
|
+
client = Noiseless.connections.client(@connection)
|
|
32
|
+
client.update_document(
|
|
33
|
+
index: index_name,
|
|
34
|
+
id: document_id,
|
|
35
|
+
changes: changes,
|
|
36
|
+
refresh: refresh,
|
|
37
|
+
**
|
|
38
|
+
)
|
|
39
|
+
else
|
|
40
|
+
# Fall back to full document update
|
|
41
|
+
index_document(refresh: refresh, **)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete_document(refresh: false, **)
|
|
46
|
+
client = Noiseless.connections.client(@connection)
|
|
47
|
+
client.delete_document(
|
|
48
|
+
index: index_name,
|
|
49
|
+
id: document_id,
|
|
50
|
+
refresh: refresh,
|
|
51
|
+
**
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def document_exists?
|
|
56
|
+
client = Noiseless.connections.client(@connection)
|
|
57
|
+
client.document_exists?(
|
|
58
|
+
index: index_name,
|
|
59
|
+
id: document_id
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :model_instance
|
|
66
|
+
|
|
67
|
+
def build_document
|
|
68
|
+
if model_instance.respond_to?(:to_search_document)
|
|
69
|
+
model_instance.to_search_document
|
|
70
|
+
elsif model_instance.respond_to?(:to_h)
|
|
71
|
+
model_instance.to_h
|
|
72
|
+
elsif model_instance.respond_to?(:attributes)
|
|
73
|
+
model_instance.attributes
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def document_id
|
|
78
|
+
if model_instance.respond_to?(:id)
|
|
79
|
+
model_instance.id
|
|
80
|
+
elsif model_instance.respond_to?(:[])
|
|
81
|
+
model_instance[:id] || model_instance["id"]
|
|
82
|
+
else
|
|
83
|
+
model_instance.object_id
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def index_name
|
|
88
|
+
@index_name ||= if model_instance.class.respond_to?(:search_index)
|
|
89
|
+
Array(model_instance.class.search_index).first
|
|
90
|
+
else
|
|
91
|
+
model_instance.class.name.demodulize.underscore.pluralize
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def supports_dirty_tracking?
|
|
96
|
+
model_instance.respond_to?(:changed_attributes) ||
|
|
97
|
+
model_instance.respond_to?(:changes) ||
|
|
98
|
+
model_instance.respond_to?(:changed?)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def has_changes?
|
|
102
|
+
return true unless supports_dirty_tracking?
|
|
103
|
+
|
|
104
|
+
if model_instance.respond_to?(:changed?)
|
|
105
|
+
model_instance.changed?
|
|
106
|
+
elsif model_instance.respond_to?(:changes)
|
|
107
|
+
!model_instance.changes.empty?
|
|
108
|
+
elsif model_instance.respond_to?(:changed_attributes)
|
|
109
|
+
!model_instance.changed_attributes.empty?
|
|
110
|
+
else
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_changes
|
|
116
|
+
changes = {}
|
|
117
|
+
|
|
118
|
+
if model_instance.respond_to?(:changes)
|
|
119
|
+
# ActiveModel::Dirty style changes hash
|
|
120
|
+
model_instance.changes.each do |attr, (_old_value, new_value)|
|
|
121
|
+
changes[attr] = new_value
|
|
122
|
+
end
|
|
123
|
+
elsif model_instance.respond_to?(:changed_attributes)
|
|
124
|
+
# Get current values for changed attributes
|
|
125
|
+
model_instance.changed_attributes.each_key do |attr|
|
|
126
|
+
if model_instance.respond_to?(attr)
|
|
127
|
+
changes[attr] = model_instance.public_send(attr)
|
|
128
|
+
elsif model_instance.respond_to?(:[])
|
|
129
|
+
changes[attr] = model_instance[attr]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
changes
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
module DSL
|
|
5
|
+
module ClassMethods
|
|
6
|
+
def search_index(*names)
|
|
7
|
+
@index_names = names.flatten.map(&:to_s) if names.any?
|
|
8
|
+
@index_names
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def index_name(name = nil)
|
|
12
|
+
@index_name = name.to_s if name
|
|
13
|
+
@index_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def searchable_fields(*fields)
|
|
17
|
+
@searchable_fields = fields if fields.any?
|
|
18
|
+
@searchable_fields
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def adapter(name = nil)
|
|
22
|
+
@adapter_name = name if name
|
|
23
|
+
@adapter_name || Noiseless.config.default_adapter
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connection(name = nil)
|
|
27
|
+
@connection_name = name if name
|
|
28
|
+
@connection_name || Noiseless.config.default_connection
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mapping(&block)
|
|
32
|
+
@mapping_block = block if block
|
|
33
|
+
@mapping_block
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def import(*, **)
|
|
37
|
+
BulkImporter.new(self).import(*, **)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def import_scoped(scope, **)
|
|
41
|
+
BulkImporter.new(self).import_scoped(scope, **)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reindex(**)
|
|
45
|
+
BulkImporter.new(self).reindex(**)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bulk_importer(connection: nil)
|
|
49
|
+
BulkImporter.new(self, connection: connection)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def searchable(**)
|
|
53
|
+
include Callbacks unless included_modules.include?(Callbacks)
|
|
54
|
+
include DSL::InstanceMethods unless included_modules.include?(DSL::InstanceMethods)
|
|
55
|
+
|
|
56
|
+
auto_index(true, **)
|
|
57
|
+
|
|
58
|
+
# Register the model in the global registry
|
|
59
|
+
Noiseless.register_model(self, searchable: true, **)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def multi_search(models: nil, indexes: nil, connection: nil, &block)
|
|
63
|
+
search_instance = MultiSearch.new(
|
|
64
|
+
models: models || [self],
|
|
65
|
+
indexes: indexes,
|
|
66
|
+
connection: connection || self.connection
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if block
|
|
70
|
+
search_instance.search(&block)
|
|
71
|
+
else
|
|
72
|
+
search_instance
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def page(num = nil)
|
|
77
|
+
Pagination::SearchPaginator.new(self, page: num)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def per(num)
|
|
81
|
+
Pagination::SearchPaginator.new(self, per_page: num)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
module InstanceMethods
|
|
86
|
+
def index_document(**)
|
|
87
|
+
DocumentManager.new(self).index_document(**)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update_document(**)
|
|
91
|
+
DocumentManager.new(self).update_document(**)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete_document(**)
|
|
95
|
+
DocumentManager.new(self).delete_document(**)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def document_exists?
|
|
99
|
+
DocumentManager.new(self).document_exists?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def document_manager(connection: nil)
|
|
103
|
+
DocumentManager.new(self, connection: connection)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Noiseless
|
|
6
|
+
module Generators
|
|
7
|
+
class ApplicationSearchGenerator < Rails::Generators::Base
|
|
8
|
+
desc "Generate ApplicationSearch class for your application"
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def create_application_search
|
|
13
|
+
create_file "app/search/application_search.rb", <<~RUBY
|
|
14
|
+
# frozen_string_literal: true
|
|
15
|
+
|
|
16
|
+
# Base class for all search models
|
|
17
|
+
class ApplicationSearch < Noiseless::Model
|
|
18
|
+
# Inherits static and dynamic search methods using default_connection
|
|
19
|
+
end
|
|
20
|
+
RUBY
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|