knitsearch 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/README.md +652 -0
- data/lib/generators/knitsearch/install/install_generator.rb +208 -0
- data/lib/generators/knitsearch/install/templates/migration.rb.tt +7 -0
- data/lib/generators/knitsearch/multisearch_install/multisearch_install_generator.rb +89 -0
- data/lib/knitsearch/document.rb +12 -0
- data/lib/knitsearch/engine.rb +22 -0
- data/lib/knitsearch/fuzzy_corrector.rb +79 -0
- data/lib/knitsearch/has_many_dependent.rb +62 -0
- data/lib/knitsearch/has_many_through_join_dependent.rb +47 -0
- data/lib/knitsearch/has_many_through_target_dependent.rb +54 -0
- data/lib/knitsearch/highlighter.rb +36 -0
- data/lib/knitsearch/levenshtein.rb +35 -0
- data/lib/knitsearch/migration.rb +235 -0
- data/lib/knitsearch/model.rb +613 -0
- data/lib/knitsearch/multisearchable.rb +24 -0
- data/lib/knitsearch/multisearchable_sync.rb +38 -0
- data/lib/knitsearch/query.rb +57 -0
- data/lib/knitsearch/version.rb +5 -0
- data/lib/knitsearch.rb +129 -0
- data/lib/tasks/knitsearch.rake +33 -0
- metadata +125 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
require "active_record"
|
|
7
|
+
|
|
8
|
+
module Knitsearch
|
|
9
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
10
|
+
include ::Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
desc "Create FTS5 search index for a model. Usage: " \
|
|
13
|
+
"bin/rails generate knitsearch:install Article title body"
|
|
14
|
+
|
|
15
|
+
argument :model_name, type: :string, banner: "MODEL"
|
|
16
|
+
argument :columns, type: :array, default: [], banner: "column1 column2 ..."
|
|
17
|
+
|
|
18
|
+
class_option :dictionary, type: :string, default: "simple",
|
|
19
|
+
desc: "Dictionary for stemming: simple (none), english (default: simple)"
|
|
20
|
+
class_option :tokenizer, type: :string, default: nil,
|
|
21
|
+
desc: "[DEPRECATED] Use --dictionary instead"
|
|
22
|
+
class_option :associated, type: :array, default: [],
|
|
23
|
+
desc: "Associated fields to index, format: assoc:column. Repeat for multiple. Example: --associated user:display_name --associated tags:name"
|
|
24
|
+
|
|
25
|
+
source_root File.expand_path("templates", __dir__)
|
|
26
|
+
|
|
27
|
+
def self.next_migration_number(dirname)
|
|
28
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify_sqlite_adapter
|
|
32
|
+
adapter = primary_adapter_from_configuration
|
|
33
|
+
return if adapter == "sqlite3"
|
|
34
|
+
|
|
35
|
+
raise ::Thor::Error,
|
|
36
|
+
"knitsearch requires SQLite. Detected adapter: #{adapter.inspect}. " \
|
|
37
|
+
"Use pg_search or your database's native FTS instead."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verify_columns_provided
|
|
41
|
+
return unless columns.empty?
|
|
42
|
+
|
|
43
|
+
raise ::Thor::Error,
|
|
44
|
+
"knitsearch:install requires at least one column to index. " \
|
|
45
|
+
"Example: bin/rails generate knitsearch:install Article title body"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def verify_table_exists
|
|
49
|
+
return if table_exists?(source_table_name)
|
|
50
|
+
|
|
51
|
+
raise ::Thor::Error,
|
|
52
|
+
"Table `#{source_table_name}` does not exist. " \
|
|
53
|
+
"Run `bin/rails db:migrate` first to create the #{model_name} table."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def verify_column_names
|
|
57
|
+
invalid = columns.reject { |c| c.match?(/\A[a-z_][a-z0-9_]*\z/) }
|
|
58
|
+
return if invalid.empty?
|
|
59
|
+
|
|
60
|
+
raise ::Thor::Error,
|
|
61
|
+
"Column names must be lowercase identifiers (letters, digits, underscores). " \
|
|
62
|
+
"Invalid: #{invalid.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verify_migration_not_exists
|
|
66
|
+
pattern = File.join(destination_root, "db/migrate/*_create_#{fts_table_name}.rb")
|
|
67
|
+
existing = Dir.glob(pattern)
|
|
68
|
+
return if existing.empty?
|
|
69
|
+
|
|
70
|
+
raise ::Thor::Error,
|
|
71
|
+
"A migration for #{fts_table_name} already exists: #{File.basename(existing.first)}. " \
|
|
72
|
+
"Delete it first if you want to regenerate."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def create_migration
|
|
76
|
+
timestamp = self.class.next_migration_number("db/migrate")
|
|
77
|
+
migration_filename = "#{timestamp}_create_#{fts_table_name}.rb"
|
|
78
|
+
migration_path = File.join(destination_root, "db/migrate", migration_filename)
|
|
79
|
+
|
|
80
|
+
if options[:tokenizer].present?
|
|
81
|
+
puts "WARNING: --tokenizer is deprecated. Use --dictionary instead."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
rich_text_cols = detect_rich_text_columns
|
|
85
|
+
dictionary = options[:dictionary] || "simple"
|
|
86
|
+
associated = parse_associated_against
|
|
87
|
+
|
|
88
|
+
associated_clause = associated.any? ? ",\n associated_against: #{associated.inspect}" : ""
|
|
89
|
+
|
|
90
|
+
migration_content = <<~RUBY
|
|
91
|
+
class Create#{fts_table_name.camelize} < ActiveRecord::Migration#{migration_version}
|
|
92
|
+
include Knitsearch::Migration
|
|
93
|
+
|
|
94
|
+
def up
|
|
95
|
+
create_searchable_table #{source_table_name.inspect},
|
|
96
|
+
columns: #{columns.inspect},
|
|
97
|
+
dictionary: #{dictionary.inspect},
|
|
98
|
+
rich_text_columns: #{rich_text_cols.inspect}#{associated_clause}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def down
|
|
102
|
+
drop_searchable_table #{source_table_name.inspect}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
RUBY
|
|
106
|
+
|
|
107
|
+
create_file migration_path, migration_content
|
|
108
|
+
|
|
109
|
+
puts "\nMigration created: #{migration_path}"
|
|
110
|
+
puts "\nNext steps:"
|
|
111
|
+
puts " 1. bin/rails db:migrate"
|
|
112
|
+
if associated.any?
|
|
113
|
+
puts " 2. Add to #{model_name} model:"
|
|
114
|
+
puts " searchable_by("
|
|
115
|
+
puts " against: { #{columns.map { |c| "#{c}: \"A\"" }.join(", ")} },"
|
|
116
|
+
puts " associated_against: #{associated.inspect}"
|
|
117
|
+
puts " )"
|
|
118
|
+
puts " 3. bin/rails knitsearch:backfill[#{model_name}]"
|
|
119
|
+
else
|
|
120
|
+
puts " 2. Add to #{model_name} model:"
|
|
121
|
+
puts " searchable_by(against: { #{columns.map { |c| "#{c}: \"A\"" }.join(", ")} })"
|
|
122
|
+
puts " 3. bin/rails knitsearch:backfill[#{model_name}]"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
no_tasks do
|
|
127
|
+
def model_class
|
|
128
|
+
@model_class ||= model_name.classify.constantize
|
|
129
|
+
rescue ::NameError
|
|
130
|
+
raise ::Thor::Error,
|
|
131
|
+
"Could not find model #{model_name}. " \
|
|
132
|
+
"Generate it first: bin/rails generate model #{model_name}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def source_table_name
|
|
136
|
+
model_class.table_name
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def fts_table_name
|
|
140
|
+
"#{source_table_name}_fts"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def migration_class_name
|
|
144
|
+
"Create#{fts_table_name.camelize}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def migration_version
|
|
148
|
+
"[#{::Rails::VERSION::MAJOR}.#{::Rails::VERSION::MINOR}]"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def table_exists?(table_name)
|
|
152
|
+
::ActiveRecord::Base.connection.table_exists?(table_name)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def detect_rich_text_columns
|
|
156
|
+
columns.select do |col|
|
|
157
|
+
model_class.respond_to?(:rich_text_attributes) &&
|
|
158
|
+
model_class.rich_text_attributes.include?(col.to_sym)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse_associated_against
|
|
163
|
+
return {} if options[:associated].empty?
|
|
164
|
+
|
|
165
|
+
result = {}
|
|
166
|
+
|
|
167
|
+
options[:associated].each do |item|
|
|
168
|
+
parts = item.split(":")
|
|
169
|
+
if parts.size < 2
|
|
170
|
+
raise ::Thor::Error,
|
|
171
|
+
"Invalid --associated format: #{item.inspect}. " \
|
|
172
|
+
"Expected: association:column (e.g., user:name) or association:column:weight (e.g., tags:name:C)"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
assoc_name = parts[0].to_sym
|
|
176
|
+
column_name = parts[1].to_sym
|
|
177
|
+
weight = parts[2]&.upcase || "C"
|
|
178
|
+
|
|
179
|
+
unless [:A, :B, :C, :D].include?(weight.to_sym)
|
|
180
|
+
raise ::Thor::Error,
|
|
181
|
+
"Invalid weight for #{assoc_name}:#{column_name}: #{weight.inspect}. " \
|
|
182
|
+
"Must be A, B, C, or D."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
unless model_class.reflect_on_association(assoc_name)
|
|
186
|
+
raise ::Thor::Error,
|
|
187
|
+
"#{model_name} does not have association #{assoc_name.inspect}. " \
|
|
188
|
+
"Check your model's association declarations."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
result[assoc_name] = [column_name] unless result[assoc_name]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
result
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
def primary_adapter_from_configuration
|
|
200
|
+
env_name = (defined?(::Rails) && ::Rails.respond_to?(:env)) ? ::Rails.env.to_s : (ENV["RAILS_ENV"] || "development")
|
|
201
|
+
configs = ::ActiveRecord::Base.configurations.configs_for(env_name: env_name)
|
|
202
|
+
return nil if configs.empty?
|
|
203
|
+
|
|
204
|
+
primary = configs.find { |c| c.name == "primary" } || configs.first
|
|
205
|
+
primary.adapter
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
include Knitsearch::Migration
|
|
3
|
+
|
|
4
|
+
def up
|
|
5
|
+
create_searchable_table <%= source_table_name.inspect %>, columns: <%= columns.inspect %>, tokenizer: <%= options[:tokenizer].to_sym.inspect %>
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
require "active_record"
|
|
7
|
+
|
|
8
|
+
module Knitsearch
|
|
9
|
+
class MultisearchInstallGenerator < ::Rails::Generators::Base
|
|
10
|
+
include ::Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
desc "Create FTS5 multi-model search index. Usage: bin/rails generate knitsearch:multisearch_install"
|
|
13
|
+
|
|
14
|
+
class_option :force, type: :boolean, default: false, desc: "Overwrite if migration already exists"
|
|
15
|
+
|
|
16
|
+
source_root File.expand_path("templates", __dir__)
|
|
17
|
+
|
|
18
|
+
def self.next_migration_number(dirname)
|
|
19
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def verify_sqlite_adapter
|
|
23
|
+
adapter = primary_adapter_from_configuration
|
|
24
|
+
return if adapter == "sqlite3"
|
|
25
|
+
|
|
26
|
+
raise ::Thor::Error,
|
|
27
|
+
"knitsearch multi-model search requires SQLite. Detected adapter: #{adapter.inspect}. " \
|
|
28
|
+
"Use a different full-text search solution for your database."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify_table_not_exists
|
|
32
|
+
return if !table_exists?("knitsearches")
|
|
33
|
+
|
|
34
|
+
return if options[:force]
|
|
35
|
+
|
|
36
|
+
raise ::Thor::Error,
|
|
37
|
+
"Table `knitsearches` already exists. " \
|
|
38
|
+
"Run `bin/rails generate knitsearch:multisearch_install --force` to overwrite."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_migration
|
|
42
|
+
timestamp = self.class.next_migration_number("db/migrate")
|
|
43
|
+
migration_filename = "#{timestamp}_create_knitsearches_multisearch.rb"
|
|
44
|
+
migration_path = File.join(destination_root, "db/migrate", migration_filename)
|
|
45
|
+
|
|
46
|
+
migration_content = <<~RUBY
|
|
47
|
+
class CreateKnitsearchesMultisearch < ActiveRecord::Migration#{migration_version}
|
|
48
|
+
include Knitsearch::Migration
|
|
49
|
+
|
|
50
|
+
def up
|
|
51
|
+
create_multisearch_table
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def down
|
|
55
|
+
drop_multisearch_table
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
RUBY
|
|
59
|
+
|
|
60
|
+
create_file migration_path, migration_content
|
|
61
|
+
|
|
62
|
+
puts "\nMigration created: #{migration_path}"
|
|
63
|
+
puts "\nNext steps:"
|
|
64
|
+
puts " 1. bin/rails db:migrate"
|
|
65
|
+
puts " 2. Add `multisearchable against: [:column1, :column2]` to your models"
|
|
66
|
+
puts " 3. Run backfill for existing records: Model.knitsearch_multisearch_backfill!"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
no_tasks do
|
|
70
|
+
def migration_version
|
|
71
|
+
"[#{::Rails::VERSION::MAJOR}.#{::Rails::VERSION::MINOR}]"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def table_exists?(table_name)
|
|
75
|
+
::ActiveRecord::Base.connection.table_exists?(table_name)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
def primary_adapter_from_configuration
|
|
81
|
+
env_name = (defined?(::Rails) && ::Rails.respond_to?(:env)) ? ::Rails.env.to_s : (ENV["RAILS_ENV"] || "development")
|
|
82
|
+
configs = ::ActiveRecord::Base.configurations.configs_for(env_name: env_name)
|
|
83
|
+
return nil if configs.empty?
|
|
84
|
+
|
|
85
|
+
primary = configs.find { |c| c.name == "primary" } || configs.first
|
|
86
|
+
primary.adapter
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Knitsearch
|
|
4
|
+
class Document < ActiveRecord::Base
|
|
5
|
+
self.table_name = "knitsearches"
|
|
6
|
+
belongs_to :searchable, polymorphic: true
|
|
7
|
+
|
|
8
|
+
def self.backfill!(model_class)
|
|
9
|
+
model_class.find_each(&:knitsearch_sync_document)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Knitsearch
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
initializer "knitsearch.schema_dumper_ignore_tables" do
|
|
8
|
+
ActiveSupport.on_load(:active_record) do
|
|
9
|
+
pattern = /(_fts|_fts_data|_fts_idx|_fts_content|_fts_docsize|_fts_config|_fts_vocab)$/
|
|
10
|
+
unless ActiveRecord::SchemaDumper.ignore_tables.include?(pattern)
|
|
11
|
+
ActiveRecord::SchemaDumper.ignore_tables << pattern
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "knitsearch.hook_multisearchable" do
|
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
|
18
|
+
include Knitsearch::Multisearchable
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Knitsearch
|
|
4
|
+
module FuzzyCorrector
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
PREFIX_LENGTH = 3
|
|
8
|
+
|
|
9
|
+
def correct(query, vocab_table:, connection:, threshold:, skip_last: false)
|
|
10
|
+
return query if query.nil?
|
|
11
|
+
|
|
12
|
+
str = query.to_s
|
|
13
|
+
return query if str.strip.empty?
|
|
14
|
+
return query unless vocab_table_available?(connection, vocab_table)
|
|
15
|
+
|
|
16
|
+
tokens = str.split(/\s+/)
|
|
17
|
+
last_index = tokens.length - 1
|
|
18
|
+
|
|
19
|
+
corrected = tokens.each_with_index.map do |token, i|
|
|
20
|
+
if skip_last && i == last_index
|
|
21
|
+
token
|
|
22
|
+
else
|
|
23
|
+
correct_token(token, vocab_table: vocab_table, connection: connection, threshold: threshold)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
corrected.join(" ")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def correct_token(token, vocab_table:, connection:, threshold:)
|
|
32
|
+
lowered = token.downcase
|
|
33
|
+
return token if lowered.length < PREFIX_LENGTH
|
|
34
|
+
|
|
35
|
+
prefix = lowered[0, PREFIX_LENGTH]
|
|
36
|
+
candidates = fetch_candidates(connection, vocab_table, prefix)
|
|
37
|
+
return token if candidates.empty?
|
|
38
|
+
|
|
39
|
+
scored = candidates
|
|
40
|
+
.map { |term, cnt| [term, cnt.to_i, Knitsearch::Levenshtein.distance(lowered, term)] }
|
|
41
|
+
.select { |_, _, d| d <= threshold }
|
|
42
|
+
return token if scored.empty?
|
|
43
|
+
|
|
44
|
+
exact = scored.find { |_, _, d| d == 0 }
|
|
45
|
+
if exact
|
|
46
|
+
max_freq = scored.map { |_, c, _| c }.max
|
|
47
|
+
# Keep the user's word unless it's >1 order of magnitude less common than alternatives.
|
|
48
|
+
# Log-scale comparison is corpus-independent: works equally on small and huge indexes.
|
|
49
|
+
if Math.log10(max_freq.to_f) - Math.log10(exact[1].to_f) < 1.0
|
|
50
|
+
return exact[0]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
best = scored.min_by { |term, cnt, d| [-cnt, d, term] }
|
|
55
|
+
best ? best[0] : token
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_candidates(connection, vocab_table, prefix)
|
|
59
|
+
binds = [ActiveRecord::Relation::QueryAttribute.new(
|
|
60
|
+
"prefix", "#{prefix}%", ActiveRecord::Type::Value.new
|
|
61
|
+
)]
|
|
62
|
+
result = connection.exec_query(
|
|
63
|
+
"SELECT term, cnt FROM #{connection.quote_table_name(vocab_table)} WHERE term LIKE ?",
|
|
64
|
+
"knitsearch_fuzzy",
|
|
65
|
+
binds
|
|
66
|
+
)
|
|
67
|
+
result.rows
|
|
68
|
+
rescue
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def vocab_table_available?(connection, vocab_table)
|
|
73
|
+
connection.execute("SELECT 1 FROM #{connection.quote_table_name(vocab_table)} LIMIT 0")
|
|
74
|
+
true
|
|
75
|
+
rescue
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Knitsearch
|
|
6
|
+
module HasManyDependent
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Proc form, not `after_save_commit :knitsearch_refresh_has_many_parents`.
|
|
11
|
+
# Symbol-form after_commit callbacks silently no-op when the target method
|
|
12
|
+
# is defined on a module included into the class — the callback registers
|
|
13
|
+
# but dispatch never reaches the method. Procs work.
|
|
14
|
+
after_save_commit { |record| record.knitsearch_refresh_has_many_parents }
|
|
15
|
+
after_destroy_commit { |record| record.knitsearch_refresh_has_many_parents }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def knitsearch_refresh_has_many_parents
|
|
19
|
+
dependents = Knitsearch.has_many_dependents[self.class]
|
|
20
|
+
return unless dependents
|
|
21
|
+
|
|
22
|
+
dependents.each do |dependent|
|
|
23
|
+
parent_class = dependent[:parent]
|
|
24
|
+
inverse_fk_sym = dependent[:inverse_fk]
|
|
25
|
+
shadow_map = dependent[:columns]
|
|
26
|
+
parent_assoc = dependent[:parent_assoc]
|
|
27
|
+
|
|
28
|
+
# Determine which parents to update
|
|
29
|
+
parents_to_update = []
|
|
30
|
+
|
|
31
|
+
# Current parent (if FK is set)
|
|
32
|
+
current_fk_value = read_attribute(inverse_fk_sym)
|
|
33
|
+
if current_fk_value.present?
|
|
34
|
+
parents_to_update << current_fk_value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Previous parent (if FK was changed)
|
|
38
|
+
if saved_change_to_attribute?(inverse_fk_sym)
|
|
39
|
+
old_fk_value = saved_changes[inverse_fk_sym]&.first
|
|
40
|
+
if old_fk_value.present?
|
|
41
|
+
parents_to_update << old_fk_value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Update each affected parent
|
|
46
|
+
parents_to_update.uniq.each do |parent_id|
|
|
47
|
+
parent = parent_class.find_by(id: parent_id)
|
|
48
|
+
next unless parent
|
|
49
|
+
|
|
50
|
+
# Recompute shadow columns for this parent
|
|
51
|
+
updates = {}
|
|
52
|
+
shadow_map.each do |shadow_col, source_col|
|
|
53
|
+
values = parent.send(parent_assoc).pluck(source_col).compact.map(&:to_s)
|
|
54
|
+
updates[shadow_col] = values.any? ? values.join(" ") : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
parent.update_columns(updates)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Knitsearch
|
|
6
|
+
module HasManyThroughJoinDependent
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Proc form, not `after_save_commit :knitsearch_refresh_through_parent_from_join`.
|
|
11
|
+
# Symbol-form after_commit callbacks silently no-op when the target method
|
|
12
|
+
# is defined on a module included into the class — the callback registers
|
|
13
|
+
# but dispatch never reaches the method. Procs work.
|
|
14
|
+
after_create_commit { |record| record.knitsearch_refresh_through_parent_from_join }
|
|
15
|
+
after_destroy_commit { |record| record.knitsearch_refresh_through_parent_from_join }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def knitsearch_refresh_through_parent_from_join
|
|
19
|
+
dependents = Knitsearch.has_many_through_dependents[self.class]
|
|
20
|
+
return unless dependents
|
|
21
|
+
|
|
22
|
+
dependents.each do |dependent|
|
|
23
|
+
parent_class = dependent[:parent_class]
|
|
24
|
+
parent_fk = dependent[:parent_fk]
|
|
25
|
+
parent_assoc = dependent[:parent_assoc]
|
|
26
|
+
shadow_map = dependent[:columns]
|
|
27
|
+
|
|
28
|
+
# Read the parent FK from the join row
|
|
29
|
+
parent_id = read_attribute(parent_fk)
|
|
30
|
+
next unless parent_id.present?
|
|
31
|
+
|
|
32
|
+
# Find the parent and refresh its shadow columns
|
|
33
|
+
parent = parent_class.find_by(id: parent_id)
|
|
34
|
+
next unless parent
|
|
35
|
+
|
|
36
|
+
# Recompute shadow columns for this parent
|
|
37
|
+
updates = {}
|
|
38
|
+
shadow_map.each do |shadow_col, source_col|
|
|
39
|
+
values = parent.send(parent_assoc).pluck(source_col).compact.map(&:to_s)
|
|
40
|
+
updates[shadow_col] = values.any? ? values.join(" ") : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
parent.update_columns(updates)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Knitsearch
|
|
6
|
+
module HasManyThroughTargetDependent
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Proc form, not `after_update_commit :knitsearch_refresh_through_parents_from_target`.
|
|
11
|
+
# Symbol-form after_commit callbacks silently no-op when the target method
|
|
12
|
+
# is defined on a module included into the class — the callback registers
|
|
13
|
+
# but dispatch never reaches the method. Procs work.
|
|
14
|
+
after_update_commit { |record| record.knitsearch_refresh_through_parents_from_target }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def knitsearch_refresh_through_parents_from_target
|
|
18
|
+
dependents = Knitsearch.has_many_through_target_dependents[self.class]
|
|
19
|
+
return unless dependents
|
|
20
|
+
|
|
21
|
+
dependents.each do |dependent|
|
|
22
|
+
join_class = dependent[:join_class]
|
|
23
|
+
parent_class = dependent[:parent_class]
|
|
24
|
+
parent_fk = dependent[:parent_fk]
|
|
25
|
+
target_fk = dependent[:target_fk]
|
|
26
|
+
parent_assoc = dependent[:parent_assoc]
|
|
27
|
+
shadow_map = dependent[:columns]
|
|
28
|
+
source_columns = dependent[:source_columns]
|
|
29
|
+
|
|
30
|
+
# Guard: only refresh if any indexed source column actually changed
|
|
31
|
+
changed_indexed_columns = saved_changes.keys.map(&:to_sym) & source_columns.map(&:to_sym)
|
|
32
|
+
return if changed_indexed_columns.empty?
|
|
33
|
+
|
|
34
|
+
# Find all parent IDs that have this target through any join row
|
|
35
|
+
parent_ids = join_class.where(target_fk => id).pluck(parent_fk).uniq
|
|
36
|
+
|
|
37
|
+
# Refresh each affected parent
|
|
38
|
+
parent_ids.each do |parent_id|
|
|
39
|
+
parent = parent_class.find_by(id: parent_id)
|
|
40
|
+
next unless parent
|
|
41
|
+
|
|
42
|
+
# Recompute shadow columns for this parent
|
|
43
|
+
updates = {}
|
|
44
|
+
shadow_map.each do |shadow_col, source_col|
|
|
45
|
+
values = parent.send(parent_assoc).pluck(source_col).compact.map(&:to_s)
|
|
46
|
+
updates[shadow_col] = values.any? ? values.join(" ") : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
parent.update_columns(updates)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Knitsearch
|
|
6
|
+
# HTML highlighter for search results. Replaces placeholder marks inserted by
|
|
7
|
+
# FTS5's highlight() function with <mark> tags. The marks are control characters
|
|
8
|
+
# chosen to be unlikely in user content.
|
|
9
|
+
module Highlighter
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
def render(text)
|
|
13
|
+
return nil if text.nil?
|
|
14
|
+
|
|
15
|
+
# Escape user content FIRST, then convert sentinels to <mark>. Reordering
|
|
16
|
+
# this would render user-stored HTML verbatim and produce stored XSS.
|
|
17
|
+
CGI.escapeHTML(text.to_s)
|
|
18
|
+
.gsub(CGI.escapeHTML(opening_mark), "<mark>")
|
|
19
|
+
.gsub(CGI.escapeHTML(closing_mark), "</mark>")
|
|
20
|
+
.html_safe
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def opening_mark
|
|
24
|
+
OPENING_MARK
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def closing_mark
|
|
28
|
+
CLOSING_MARK
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
OPENING_MARK = "\x02knitsearch_open\x03"
|
|
34
|
+
CLOSING_MARK = "\x02knitsearch_close\x03"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Knitsearch
|
|
4
|
+
module Levenshtein
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def distance(a, b)
|
|
8
|
+
a = (a || "").to_s
|
|
9
|
+
b = (b || "").to_s
|
|
10
|
+
return b.length if a.empty?
|
|
11
|
+
return a.length if b.empty?
|
|
12
|
+
|
|
13
|
+
# Ensure b is the shorter — O(min(a,b)) space
|
|
14
|
+
a, b = b, a if a.length < b.length
|
|
15
|
+
|
|
16
|
+
prev = (0..b.length).to_a
|
|
17
|
+
curr = Array.new(b.length + 1)
|
|
18
|
+
|
|
19
|
+
a.each_char.with_index(1) do |ac, i|
|
|
20
|
+
curr[0] = i
|
|
21
|
+
b.each_char.with_index(1) do |bc, j|
|
|
22
|
+
cost = ac == bc ? 0 : 1
|
|
23
|
+
curr[j] = [
|
|
24
|
+
curr[j - 1] + 1, # insert
|
|
25
|
+
prev[j] + 1, # delete
|
|
26
|
+
prev[j - 1] + cost # substitute
|
|
27
|
+
].min
|
|
28
|
+
end
|
|
29
|
+
prev, curr = curr, prev
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
prev[b.length]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|