activerecord-mysql-search 0.2.0 → 0.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dec7afc185ebe9baa4de931681c27b4718d138126111fbeb1467819b958e5883
4
- data.tar.gz: 21d49c730ea6064c0c6ba1a1993dd24e09273463e082d5a1d57fff9fe2ad17a9
3
+ metadata.gz: 19cb0101f1cfe861ccdf0defa71c17f4608e17e99e7428f5e5ad00aa2a646be5
4
+ data.tar.gz: 758c80b143460cc517d826af5fd701d19adbe46dddbec49aa1e245cf48f37201
5
5
  SHA512:
6
- metadata.gz: 706aa3813e15aa8814ab3f2c908de266db1ab65d09a7b96f00bc6234cabcc6579dd8413db97bcd6b4754c6375e1464156dcc5c930345cc193eaa532ee4794f03
7
- data.tar.gz: 0ce9414a415f48805420657fbc14c9e7b7f7111d2e4641dd6b127601c5100915ffaba100fea62023c9a0735d044d9362e436761e39f5c98e6632fdf3d324528b
6
+ metadata.gz: 5c0d1de1bdcf4d17b6b30826fcd7f38eb6f81410fe8d27794379e71d7b67cadb7d5c78f465ecc143bc0b7a23dbea022edefb44f90cf558e61791180b336aaf1d
7
+ data.tar.gz: 8c70d839e870efe25a3525baa34053e0b951bdb32b12ebc8c0e84fbc8ab52ca9a8658d68a4a4c9ddc6fcbff0a533b096745ed0ea9fd6aade945c30090d4cca5c
data/Appraisals CHANGED
@@ -16,3 +16,11 @@ end
16
16
  appraise 'rails_8.0' do
17
17
  gem 'rails', '~> 8.0.0'
18
18
  end
19
+
20
+ appraise 'rails_8.0' do
21
+ gem 'rails', '~> 8.0.0'
22
+ end
23
+
24
+ appraise 'rails_8.1' do
25
+ gem 'rails', '~> 8.1.0'
26
+ end
data/CHANGELOG.md CHANGED
@@ -1,23 +1,36 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2025-07-22
3
+ ## [0.2.3] - 2026-09-11
4
4
 
5
- - Initial release
5
+ - Adds the `boolean_full_text_search` scope for MySQL boolean full-text queries
6
+ - Adds integration coverage for required (`+`) and excluded (`-`) search terms
6
7
 
7
- ## [0.1.1] - 2025-08-05
8
+ ## [0.2.1] - 2026-07-15
8
9
 
9
- - Fixes load paths to rake tasks
10
+ - Adds automatic loading of source classes on Rails startup via new `autoload_sources` configuration option (default: true)
11
+ - Adds `load_source_classes!` method for manually loading source classes
12
+ - Fixes source actualization query composition to preserve base STI scope while appending OR conditions for associated updates
13
+ - Adds regression coverage for STI subclass source relations with associated updates
14
+ - Extracts Arel `AGAINST` integration into a dedicated extension file
10
15
 
11
- ## [0.1.2] - 2025-08-12
16
+ ## [0.2.0] - 2025-09-25
12
17
 
13
- - Removes code of conduct
18
+ - Removes text normalization from default `:text` formatter
19
+ - Search term passed as argument to the scope `.full_text_search` is not pre-normalized anymore
20
+ - Adds `register_format` method to register custom formatters
14
21
 
15
22
  ## [0.1.3] - 2025-08-06
16
23
 
17
24
  - Move rake tasks to the proper place + explicit tasks load
18
25
 
19
- ## [0.2.0] - 2025-09-25
26
+ ## [0.1.2] - 2025-08-12
20
27
 
21
- - Removes text normalization from default `:text` formatter
22
- - Search term passed as argument to the scope `.full_text_search` is not pre-normalized anymore
23
- - Adds `register_format` method to register custom formatters
28
+ - Removes code of conduct
29
+
30
+ ## [0.1.1] - 2025-08-05
31
+
32
+ - Fixes load paths to rake tasks
33
+
34
+ ## [0.1.0] - 2025-07-22
35
+
36
+ - Initial release
data/Gemfile CHANGED
@@ -16,6 +16,7 @@ gem 'database_cleaner-active_record'
16
16
  gem 'rspec'
17
17
 
18
18
  gem 'rubocop'
19
+ gem 'rubocop-performance'
19
20
  gem 'rubocop-rails'
20
21
  gem 'rubocop-rake'
21
22
  gem 'rubocop-rspec'
data/README.md CHANGED
@@ -121,6 +121,24 @@ This command populates the `search_indices` table with existing data from your m
121
121
  results = Article.full_text_search("Ruby on Rails")
122
122
  ```
123
123
 
124
+ `full_text_search` uses MySQL's natural-language full-text search. Use
125
+ `boolean_full_text_search` when you need operators such as `+` for required
126
+ terms, `-` for excluded terms, or quotes and wildcards supported by MySQL's
127
+ boolean full-text syntax:
128
+
129
+ ```ruby
130
+ results = Article.boolean_full_text_search("+Ruby +Rails -legacy")
131
+ ```
132
+
133
+ Both scopes support searching one or more search-index columns:
134
+
135
+ ```ruby
136
+ results = Product.boolean_full_text_search(
137
+ "+Ruby +Rails",
138
+ search_column: %i[content seller_extra]
139
+ )
140
+ ```
141
+
124
142
  **That’s it!** Users now get fast, scalable, and relevant search—no complex SQL, external services, or maintenance headaches.
125
143
 
126
144
  ## Advanced Scenarios: Multi-Column Search for Roles and Contexts
@@ -213,6 +231,13 @@ MySQL::Search.configure do |config|
213
231
  # Path to search source classes (default: 'app/search_sources')
214
232
  config.sources_path = 'app/search_sources'
215
233
 
234
+ # Automatically load source classes on Rails startup (default: true)
235
+ # Loading of source classes assigns callbacks to the target models
236
+ # to keep the search index updated. In case of loading issue you can load
237
+ # the source classes manually with `MySQL::Search.load_source_classes!`
238
+ # and disable automatic initialization.
239
+ config.autoload_sources = true
240
+
216
241
  # Automatically update search index when models change (default: true)
217
242
  config.automatic_update = true
218
243
 
@@ -224,6 +249,9 @@ MySQL::Search.configure do |config|
224
249
 
225
250
  # Calendar week format (default: 'week %V')
226
251
  config.calendar_week_format = 'week %V'
252
+
253
+ # Register custom formatters
254
+ config.register_format(:upcase) { |value| value.to_s.upcase }
227
255
  end
228
256
  ```
229
257
 
@@ -237,7 +265,7 @@ end
237
265
 
238
266
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
239
267
 
240
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
268
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update `spec.version` in `activerecord-mysql-search.gemspec`, commit the change, and then run `bundle exec rake release`. This creates a git tag for the version, pushes the commit and tag, and pushes the `.gem` file to [rubygems.org](https://rubygems.org).
241
269
 
242
270
  ## Contributing
243
271
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = 'activerecord-mysql-search'
5
- spec.version = '0.2.0'
5
+ spec.version = '0.2.3'
6
6
  spec.authors = ['Daydream Unicorn GmbH & Co. KG']
7
7
  spec.email = ['hello@daydreamunicorn.com']
8
8
 
@@ -13,8 +13,10 @@ gem 'rails', '~> 7.0.0'
13
13
  gem 'rake', '~> 13.0'
14
14
  gem 'rspec'
15
15
  gem 'rubocop'
16
+ gem 'rubocop-performance'
16
17
  gem 'rubocop-rails'
17
18
  gem 'rubocop-rake'
18
19
  gem 'rubocop-rspec'
20
+ gem 'ruby-lsp'
19
21
 
20
22
  gemspec path: '../'
@@ -12,8 +12,10 @@ gem 'rails', '~> 7.1.0'
12
12
  gem 'rake', '~> 13.0'
13
13
  gem 'rspec'
14
14
  gem 'rubocop'
15
+ gem 'rubocop-performance'
15
16
  gem 'rubocop-rails'
16
17
  gem 'rubocop-rake'
17
18
  gem 'rubocop-rspec'
19
+ gem 'ruby-lsp'
18
20
 
19
21
  gemspec path: '../'
@@ -12,8 +12,10 @@ gem 'rails', '~> 7.2.0'
12
12
  gem 'rake', '~> 13.0'
13
13
  gem 'rspec'
14
14
  gem 'rubocop'
15
+ gem 'rubocop-performance'
15
16
  gem 'rubocop-rails'
16
17
  gem 'rubocop-rake'
17
18
  gem 'rubocop-rspec'
19
+ gem 'ruby-lsp'
18
20
 
19
21
  gemspec path: '../'
@@ -12,8 +12,10 @@ gem 'rails', '~> 8.0.0'
12
12
  gem 'rake', '~> 13.0'
13
13
  gem 'rspec'
14
14
  gem 'rubocop'
15
+ gem 'rubocop-performance'
15
16
  gem 'rubocop-rails'
16
17
  gem 'rubocop-rake'
17
18
  gem 'rubocop-rspec'
19
+ gem 'ruby-lsp'
18
20
 
19
21
  gemspec path: '../'
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file was generated by Appraisal
4
+
5
+ source 'https://rubygems.org'
6
+
7
+ gem 'appraisal'
8
+ gem 'database_cleaner-active_record'
9
+ gem 'mysql2'
10
+ gem 'pry'
11
+ gem 'rails', '~> 8.1.0'
12
+ gem 'rake', '~> 13.0'
13
+ gem 'rspec'
14
+ gem 'rubocop'
15
+ gem 'rubocop-performance'
16
+ gem 'rubocop-rails'
17
+ gem 'rubocop-rake'
18
+ gem 'rubocop-rspec'
19
+ gem 'ruby-lsp'
20
+
21
+ gemspec path: '../'
@@ -15,11 +15,7 @@ module ActiveRecord
15
15
  add_column table_name, :updated_at, 'DATETIME ON UPDATE CURRENT_TIMESTAMP', **options
16
16
  end
17
17
  end
18
- end
19
- end
20
18
 
21
- module ActiveRecord
22
- module ConnectionAdapters
23
19
  # Overrides the `timestamps` method in `TableDefinition` to use MySQL's `DATETIME ON UPDATE CURRENT_TIMESTAMP`
24
20
  # for the `updated_at` column.
25
21
  # This allows the `updated_at` column to automatically update its value whenever the row is updated.
@@ -2,21 +2,31 @@
2
2
 
3
3
  # Configure MySQL::Search settings
4
4
  MySQL::Search.configure do |config|
5
- # Defines the name of the search index activerecord model.
6
- config.search_index_class_name = 'SearchIndex'
5
+ # Model class name for search indices (default: 'SearchIndex')
6
+ # config.search_index_class_name = 'SearchIndex'
7
7
 
8
- # Location of the search sources folder
9
- config.sources_path = 'app/search_sources'
8
+ # Path to search source classes (default: 'app/search_sources')
9
+ # config.sources_path = 'app/search_sources'
10
10
 
11
- # Enables the search index to be automatically updated via source and nested models callbacks "on save"
12
- config.automatic_update = true
11
+ # Automatically load source classes on Rails startup (default: true)
12
+ # Loading of source classes assigns callbacks to the target models
13
+ # to keep the search index updated. In case of loading issue you can load
14
+ # the source classes manually with `MySQL::Search.load_source_classes!`
15
+ # and disable automatic initialization.
16
+ # config.autoload_sources = true
13
17
 
14
- # Use ActiveJob to update the search index in the background
15
- config.update_asyncronously = true
18
+ # Automatically update search index when models change (default: true)
19
+ # config.automatic_update = true
16
20
 
17
- # Defines the format for `calendar_week` formatter.
18
- config.calendar_week_format = 'week %W'
21
+ # Process index updates asynchronously (default: false)
22
+ # config.update_asyncronously = false
19
23
 
20
- # Defines the format for `date` formater.
21
- config.date_format = '%d.%m.%Y'
24
+ # Date format for date fields (default: '%d.%m.%Y')
25
+ # config.date_format = '%d.%m.%Y'
26
+
27
+ # Calendar week format (default: 'week %V')
28
+ # config.calendar_week_format = 'week %V'
29
+
30
+ # Register custom formatters
31
+ # config.register_format(:upcase) { |value| value.to_s.upcase }
22
32
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'arel'
4
+
5
+ module Arel
6
+ module Visitors
7
+ # Custom visitor for MySQL to handle the `AGAINST` clause in full-text search.
8
+ class MySQL
9
+ def visit_Arel_Nodes_Against(node, collector) # rubocop:disable Naming/MethodName
10
+ visit(node.left, collector) << ' AGAINST ('
11
+ visit(node.right, collector)
12
+ collector << ' IN BOOLEAN MODE' if node.boolean_mode?
13
+ collector << ')'
14
+ end
15
+ end
16
+ end
17
+
18
+ module Nodes
19
+ # Represents the `AGAINST` clause used in MySQL full-text search queries.
20
+ class Against < Arel::Nodes::Matches
21
+ def initialize(left, right, boolean_mode: false)
22
+ super(left, right)
23
+ @boolean_mode = boolean_mode
24
+ end
25
+
26
+ def boolean_mode?
27
+ @boolean_mode
28
+ end
29
+ end
30
+ end
31
+
32
+ # Adds a method to the `Arel::Nodes::Node` class to allow for full-text search queries.
33
+ module Predications
34
+ def against(other, boolean_mode: false)
35
+ Arel::Nodes::Against.new(self, quoted_node(other), boolean_mode: boolean_mode)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'full_text_search_query'
4
+
5
+ module MySQL
6
+ module Search
7
+ module Queries
8
+ # BooleanFullTextSearchQuery builds MySQL boolean full-text search queries.
9
+ class BooleanFullTextSearchQuery < FullTextSearchQuery
10
+ private
11
+
12
+ def search_expression(search_term, search_column)
13
+ search_indices = ::MySQL::Search.search_index_class_name.constantize.arel_table
14
+ search_columns = Array.wrap(search_column).map { |col| search_indices[col] }
15
+
16
+ Arel::Nodes::NamedFunction.new('MATCH', search_columns).against(search_term, boolean_mode: true)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,35 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'arel'
4
-
5
- module Arel
6
- module Visitors
7
- # Custom visitor for MySQL to handle the `AGAINST` clause in full-text search.
8
- class MySQL
9
- def visit_Arel_Nodes_Against(node, collector) # rubocop:disable Naming/MethodName
10
- visit(node.left, collector) << ' AGAINST ('
11
- visit(node.right, collector) << ')'
12
- end
13
- end
14
- end
15
- end
16
-
17
- module Arel
18
- module Nodes
19
- # Represents the `AGAINST` clause used in MySQL full-text search queries.
20
- class Against < Arel::Nodes::Matches
21
- end
22
- end
23
- end
24
-
25
- module Arel
26
- # Adds a method to the `Arel::Nodes::Node` class to allow for full-text search queries.
27
- module Predications
28
- def against(other)
29
- Arel::Nodes::Against.new(self, quoted_node(other))
30
- end
31
- end
32
- end
3
+ require_relative '../extensions/arel_against'
33
4
 
34
5
  module MySQL
35
6
  module Search
@@ -14,24 +14,37 @@ module MySQL
14
14
 
15
15
  def call(time_ago)
16
16
  joins_args = source_class_name.constantize.joins_args
17
- relation = source_relation.left_joins(joins_args).where(updated_at: time_ago..)
17
+ base_relation = source_relation.left_joins(joins_args)
18
+ relation = base_relation.where(source_relation.klass.arel_table[:updated_at].gteq(time_ago))
18
19
 
19
- append_conditions(relation, source_relation, joins_args, time_ago)
20
+ append_conditions(relation, source_relation.klass, joins_args, time_ago, base_relation)
20
21
  end
21
22
 
22
23
  private
23
24
 
24
- def append_conditions(relation, root_class, config, time_ago)
25
+ def append_conditions(relation, root_class, config, time_ago, base_relation)
25
26
  case config
26
- when Array then config.reduce(relation) { |rel, item| append_conditions(rel, root_class, item, time_ago) }
27
+ when Array
28
+ append_array_conditions(relation, root_class, config, time_ago, base_relation)
27
29
  when Hash
28
- relation = append_conditions(relation, root_class, config.keys, time_ago)
29
-
30
- config.reduce(relation) do |rel, (root_association, nested_config)|
31
- append_conditions(rel, association_class(root_class, root_association), nested_config, time_ago)
32
- end
30
+ append_hash_conditions(relation, root_class, config, time_ago, base_relation)
33
31
  else
34
- append_or(relation, association_class(root_class, config), time_ago)
32
+ append_or(relation, association_class(root_class, config), time_ago, base_relation)
33
+ end
34
+ end
35
+
36
+ def append_array_conditions(relation, root_class, config, time_ago, base_relation)
37
+ config.reduce(relation) do |rel, item|
38
+ append_conditions(rel, root_class, item, time_ago, base_relation)
39
+ end
40
+ end
41
+
42
+ def append_hash_conditions(relation, root_class, config, time_ago, base_relation)
43
+ relation = append_conditions(relation, root_class, config.keys, time_ago, base_relation)
44
+
45
+ config.reduce(relation) do |rel, (root_association, nested_config)|
46
+ append_conditions(rel, association_class(root_class, root_association), nested_config, time_ago,
47
+ base_relation)
35
48
  end
36
49
  end
37
50
 
@@ -39,10 +52,10 @@ module MySQL
39
52
  root_class.reflect_on_association(association).klass
40
53
  end
41
54
 
42
- def append_or(relation, model, time_ago)
55
+ def append_or(relation, model, time_ago, base_relation)
43
56
  return relation unless model.column_names.include?('updated_at')
44
57
 
45
- relation.or(model.where(updated_at: time_ago..))
58
+ relation.or(base_relation.where(model.arel_table[:updated_at].gteq(time_ago)))
46
59
  end
47
60
  end
48
61
  end
@@ -6,6 +6,10 @@ module MySQL
6
6
  class Railtie < Rails::Railtie
7
7
  railtie_name :mysql_search
8
8
 
9
+ config.to_prepare do
10
+ MySQL::Search.load_source_classes! if MySQL::Search.autoload_sources
11
+ end
12
+
9
13
  rake_tasks do
10
14
  load 'tasks/mysql/search/actualize.rake'
11
15
  load 'tasks/mysql/search/reindex.rake'
@@ -18,6 +18,10 @@ module MySQL
18
18
  scope :full_text_search, lambda { |search_term, search_column: :content|
19
19
  ::MySQL::Search::Queries::FullTextSearchQuery.new(self).call(search_term, search_column: search_column)
20
20
  }
21
+
22
+ scope :boolean_full_text_search, lambda { |search_term, search_column: :content|
23
+ ::MySQL::Search::Queries::BooleanFullTextSearchQuery.new(self).call(search_term, search_column: search_column)
24
+ }
21
25
  end
22
26
  end
23
27
  end
@@ -7,9 +7,9 @@ module MySQL
7
7
  class Formatter
8
8
  attr_reader :value, :formatter
9
9
 
10
- def self.register(name, &block)
10
+ def self.register(name, &)
11
11
  define_method(name) do
12
- block.call(value)
12
+ yield(value)
13
13
  end
14
14
  end
15
15
 
data/lib/mysql/search.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'search/searchable'
7
7
  require_relative 'search/source'
8
8
  require_relative 'search/queries/updated_sources_query'
9
9
  require_relative 'search/queries/full_text_search_query'
10
+ require_relative 'search/queries/boolean_full_text_search_query'
10
11
  require_relative 'search/updater'
11
12
  require_relative 'search/utils'
12
13
 
@@ -18,6 +19,7 @@ module MySQL
18
19
  # Runtime configuration
19
20
  mattr_accessor :automatic_update, default: true
20
21
  mattr_accessor :update_asyncronously, default: false
22
+ mattr_accessor :autoload_sources, default: true
21
23
 
22
24
  # Search Index & Sources
23
25
  mattr_accessor :search_index_class_name, default: 'SearchIndex'
@@ -36,11 +38,16 @@ module MySQL
36
38
  end
37
39
 
38
40
  def source_classes
39
- @source_classes ||= Dir.glob("#{sources_path}/**/*.rb").filter_map do |file|
41
+ Dir.glob("#{sources_path}/**/*.rb").filter_map do |file|
40
42
  file.sub("#{sources_path}/", '').sub('.rb', '').camelize.safe_constantize
41
43
  end
42
44
  end
43
45
 
46
+ # Keep a separate API entrypoint used by the Railtie initialization hook.
47
+ def load_source_classes!
48
+ source_classes
49
+ end
50
+
44
51
  def configure
45
52
  yield self
46
53
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-mysql-search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daydream Unicorn GmbH & Co. KG
@@ -32,6 +32,7 @@ files:
32
32
  - gemfiles/rails_7.1.gemfile
33
33
  - gemfiles/rails_7.2.gemfile
34
34
  - gemfiles/rails_8.0.gemfile
35
+ - gemfiles/rails_8.1.gemfile
35
36
  - lib/activerecord-mysql-search.rb
36
37
  - lib/generators/mysql/search/create_trigger_generator.rb
37
38
  - lib/generators/mysql/search/install_generator.rb
@@ -42,10 +43,12 @@ files:
42
43
  - lib/generators/mysql/search/templates/db/migrate/enable_auto_update_of_updated_at.rb
43
44
  - lib/mysql/search.rb
44
45
  - lib/mysql/search/callbacks.rb
46
+ - lib/mysql/search/extensions/arel_against.rb
45
47
  - lib/mysql/search/grabber.rb
46
48
  - lib/mysql/search/jobs.rb
47
49
  - lib/mysql/search/jobs/scheduled_updater_job.rb
48
50
  - lib/mysql/search/jobs/updater_job.rb
51
+ - lib/mysql/search/queries/boolean_full_text_search_query.rb
49
52
  - lib/mysql/search/queries/full_text_search_query.rb
50
53
  - lib/mysql/search/queries/updated_sources_query.rb
51
54
  - lib/mysql/search/railtie.rb
@@ -79,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
82
  - !ruby/object:Gem::Version
80
83
  version: '0'
81
84
  requirements: []
82
- rubygems_version: 3.6.9
85
+ rubygems_version: 4.0.16
83
86
  specification_version: 4
84
87
  summary: Full Text Search for ActiveRecord with MySQL
85
88
  test_files: []