searchcraft 0.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.
@@ -0,0 +1,218 @@
1
+ class SearchCraft::Builder
2
+ extend SearchCraft::Annotate
3
+ include SearchCraft::DependsOn
4
+ include SearchCraft::DumpSchema
5
+
6
+ # Subclass must implement view_scope or view_select_sql
7
+ def view_scope
8
+ raise NotImplementedError, "Subclass must implement view_scope or view_select_sql"
9
+ end
10
+
11
+ # By default, assumes subclass implements view_scope to return
12
+ # an ActiveRecord::Relation.
13
+ # Alternately, override view_select_sql to return a SQL string.
14
+ def view_select_sql
15
+ @_view_select_sql ||= view_scope.to_sql
16
+ end
17
+
18
+ # Override if a Builder SQL has dependencies, such as extensions or text search config
19
+ # that are required first.
20
+ def dependencies_ready?
21
+ true
22
+ end
23
+
24
+ class << self
25
+ # Iterate through subclasses, and invoke recreate_view_if_changed!
26
+ def rebuild_any_if_changed!
27
+ SearchCraft::ViewHashStore.setup_table_if_needed!
28
+
29
+ sorted_builders = sort_builders_by_dependency
30
+
31
+ # If tests, and after rails db:schema:load, the ViewHashStore table is empty.
32
+ # So just drop any views created from the schema.rb and we'll recreate them.
33
+
34
+ unless SearchCraft::ViewHashStore.any?
35
+ sorted_builders.each { |builder| builder.new.drop_view! }
36
+ end
37
+
38
+ builders_changed = []
39
+ sorted_builders.each do |builder|
40
+ changed = builder.new.recreate_view_if_changed!(builders_changed: builders_changed)
41
+ builders_changed << builder if changed
42
+ end
43
+
44
+ annotate_models!
45
+ end
46
+
47
+ def rebuild_all!
48
+ end
49
+
50
+ def recreate_indexes!
51
+ sorted_builders = sort_builders_by_dependency
52
+ sorted_builders.each { |builder| builder.new.recreate_indexes! }
53
+ end
54
+
55
+ def builders_to_rebuild
56
+ if SearchCraft.config.explicit_builder_class_names
57
+ SearchCraft.config.explicit_builder_class_names.map(&:constantize)
58
+ elsif Object.const_defined?(:Rails) && Rails.application
59
+ find_subclasses_via_rails_eager_load_paths.map(&:constantize)
60
+ else
61
+ subclasses
62
+ end
63
+ end
64
+
65
+ # Looks for subclasses of SearchCraft::Builder in Rails eager load paths
66
+ # and then any subclasses of those.
67
+ # Returns an array of class names
68
+ def find_subclasses_via_rails_eager_load_paths(known_subclass_names: [])
69
+ subclass_names = []
70
+
71
+ potential_superclass_names = known_subclass_names + ["SearchCraft::Builder"]
72
+ potential_superclass_regex = Regexp.new(potential_superclass_names.join("|"))
73
+
74
+ Rails.configuration.eager_load_paths.each do |load_path|
75
+ Dir.glob("#{load_path}/**/*.rb").each do |file|
76
+ File.readlines(file).each do |line|
77
+ if (match = line.match(/class\s+([\w:]+)\s*<\s*#{potential_superclass_regex}/))
78
+ class_name = match[1]
79
+ warn "Found #{class_name} in #{file}" unless known_subclass_names.include?(class_name)
80
+ subclass_names << class_name
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ newly_found_subclass_names = subclass_names - known_subclass_names
87
+ if newly_found_subclass_names.any?
88
+ return find_subclasses_via_rails_eager_load_paths(known_subclass_names: subclass_names)
89
+ end
90
+
91
+ subclass_names
92
+ end
93
+ end
94
+
95
+ # Produces the SQL that will create the materialized view
96
+ def view_sql
97
+ # remove trailing ; from view_sql
98
+ inner_sql = view_select_sql.gsub(/;\s*$/, "")
99
+ "CREATE MATERIALIZED VIEW #{view_name} AS (#{inner_sql}) WITH DATA;"
100
+ end
101
+
102
+ # After materialized view created, do you need indexes on its columns?
103
+ def view_indexes
104
+ {}
105
+ end
106
+
107
+ # To indicate if view has changed, we store a hash of the SQL used to create it
108
+ # TODO: include the indexes SQL too
109
+ def view_sql_hash
110
+ Digest::SHA256.hexdigest(view_sql)
111
+ end
112
+
113
+ # If missing or changed, drop and create view
114
+ # Returns false if no change required
115
+ def recreate_view_if_changed!(builders_changed: [])
116
+ if SearchCraft.debug?
117
+ warn "#{self.class.name}#recreate_view_if_changed!"
118
+ warn " builders_changed: #{builders_changed.map(&:name).join(", ")}" if builders_changed.any?
119
+ end
120
+ return unless dependencies_ready?
121
+
122
+ dependencies_changed = (@@dependencies[self.class.name] || []) & builders_changed.map(&:name)
123
+ return false unless dependencies_changed.any? ||
124
+ SearchCraft::ViewHashStore.changed?(builder: self)
125
+
126
+ if SearchCraft.debug?
127
+ if !SearchCraft::ViewHashStore.exists?(builder: self)
128
+ warn "Creating #{view_name} because it doesn't yet exist"
129
+ elsif dependencies_changed.any?
130
+ warn "Recreating #{view_name} because dependencies changed: #{dependencies_changed.join(" ")}"
131
+ else
132
+ warn "Recreating #{view_name} because SQL changed"
133
+ end
134
+ end
135
+
136
+ drop_view!
137
+ create_view!
138
+ update_hash_store!
139
+ dump_schema!
140
+
141
+ true
142
+ end
143
+
144
+ def create_view!
145
+ create_sequence!
146
+ sql_execute(view_sql)
147
+ create_indexes!
148
+ end
149
+
150
+ # Finds and drops all indexes and sequences on view, and then drops view
151
+ def drop_view!
152
+ sql_execute("DROP MATERIALIZED VIEW IF EXISTS #{view_name} CASCADE;")
153
+
154
+ sql_execute("DROP SEQUENCE IF EXISTS #{view_id_sequence_name};")
155
+
156
+ SearchCraft::ViewHashStore.reset!(builder: self)
157
+ end
158
+
159
+ def recreate_indexes!
160
+ drop_indexes!
161
+ create_indexes!
162
+ end
163
+
164
+ # Pluralized table name of class
165
+ def view_name
166
+ base_sql_name
167
+ end
168
+
169
+ protected
170
+
171
+ # CREATE SEQUENCE #{view_id_sequence_name} CYCLE;
172
+ # DROP SEQUENCE #{view_id_sequence_name};
173
+ def view_id_sequence_name
174
+ "#{base_sql_name}_seq"
175
+ end
176
+
177
+ # ProductSearchBuilder name becomes product_searches
178
+ def base_sql_name
179
+ self.class.name.gsub(/Builder$/, "").tableize.tr("/", "_")
180
+ end
181
+
182
+ def base_idx_name
183
+ "idx_#{base_sql_name}"
184
+ end
185
+
186
+ def create_sequence!
187
+ sql_execute("CREATE SEQUENCE #{view_id_sequence_name} CYCLE;")
188
+ end
189
+
190
+ def drop_indexes!
191
+ simple_view_name = view_name.gsub(/^.+\./, "")
192
+ indexes = sql_execute("SELECT indexname FROM pg_indexes WHERE tablename = '#{simple_view_name}';")
193
+ indexes.each do |index|
194
+ warn "DROP INDEX IF EXISTS #{index["indexname"]};" if SearchCraft.debug?
195
+ sql_execute("DROP INDEX IF EXISTS #{index["indexname"]};")
196
+ end
197
+ end
198
+
199
+ def create_indexes!
200
+ view_indexes.each do |index_name, index_options|
201
+ columns = index_options[:columns]
202
+ name = "#{base_idx_name}_#{index_name}"
203
+ options = index_options.except(:columns).merge({name: name})
204
+
205
+ warn "ActiveRecord::Base.connection.add_index(#{view_name.inspect}, #{columns.inspect}, #{options.inspect})" if SearchCraft.debug?
206
+ ActiveRecord::Base.connection.add_index(view_name, columns, **options)
207
+ end
208
+ end
209
+
210
+ def update_hash_store!
211
+ SearchCraft::ViewHashStore.update_for(builder: self)
212
+ end
213
+
214
+ def sql_execute(sql)
215
+ warn sql if SearchCraft.debug?
216
+ ActiveRecord::Base.connection.execute(sql)
217
+ end
218
+ end
@@ -0,0 +1,12 @@
1
+ module SearchCraft
2
+ class Configuration
3
+ attr_accessor :disable_autorebuild
4
+ attr_accessor :debug
5
+ attr_accessor :explicit_builder_class_names
6
+ attr_accessor :explicit_model_class_names
7
+
8
+ def autorebuild?
9
+ !disable_autorebuild
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,42 @@
1
+ module SearchCraft::DependsOn
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ @@dependencies = {}
6
+
7
+ def depends_on(*builder_names)
8
+ @@dependencies[name] = builder_names
9
+ end
10
+
11
+ # TODO: implement .add_index instead of #view_indexes below
12
+ def add_index(index_name, columns, unique: false, name: nil)
13
+ @indexes ||= {}
14
+ # TODO: also get indexes from @@dependencies[name]
15
+ @indexes[index_name] = {columns: columns, unique: unique, name: name}
16
+ end
17
+
18
+ def sort_builders_by_dependency
19
+ sorted = []
20
+ visited = {}
21
+
22
+ builders_to_rebuild.each do |builder|
23
+ visit(builder, visited, sorted)
24
+ end
25
+
26
+ sorted
27
+ end
28
+
29
+ def visit(builder, visited, sorted)
30
+ return if visited[builder.name]
31
+
32
+ dependency_names = @@dependencies[builder.name] || []
33
+ dependency_names.each do |dependency_name|
34
+ dependency = Object.const_get(dependency_name)
35
+ visit(dependency, visited, sorted)
36
+ end
37
+
38
+ visited[builder.name] = true
39
+ sorted << builder
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ module SearchCraft::DumpSchema
2
+ extend ActiveSupport::Concern
3
+
4
+ # If in Rails, dump schema.rb after rebuilding views
5
+ def dump_schema!
6
+ return unless Rails.env.development?
7
+ require "active_record/tasks/database_tasks"
8
+
9
+ env = Rails.env
10
+ db_configs = ActiveRecord::Base.configurations.configs_for(env_name: env)
11
+ db_configs.each do |db_config|
12
+ ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config, ActiveRecord.schema_format)
13
+ end
14
+ rescue ActiveRecord::NoDatabaseError
15
+ rescue => e
16
+ warn "Error dumping schema: #{e.message}"
17
+ pp e.backtrace
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ require "scenic"
2
+
3
+ module SearchCraft::Model
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ def read_only?
8
+ true
9
+ end
10
+ self.table_name = name.tableize.tr("/", "_")
11
+ end
12
+
13
+ class_methods do
14
+ def refresh!
15
+ Scenic.database.refresh_materialized_view(table_name, concurrently: @refresh_concurrently, cascade: false)
16
+ end
17
+
18
+ def refresh_concurrently=(value)
19
+ @refresh_concurrently = value
20
+ end
21
+ end
22
+
23
+ # Maintain a list of classes that include this module
24
+ @included_classes = []
25
+
26
+ class << self
27
+ # Class method to add a class to the list of included classes
28
+ def included(base)
29
+ @included_classes << base
30
+ super
31
+ end
32
+
33
+ def included_classes
34
+ if SearchCraft.config.explicit_model_class_names
35
+ return SearchCraft.config.explicit_model_class_names.map(&:constantize)
36
+ end
37
+ @included_classes
38
+ end
39
+
40
+ # Runs .refresh! on all classes that include SearchCraft::Model
41
+ # TODO: eager load all classes that include SearchCraft::Model;
42
+ # perhaps via Builder eager loading?
43
+ def refresh_all!
44
+ included_classes.each do |klass|
45
+ warn "Refreshing materialized view #{klass.table_name}..." unless Rails.env.test?
46
+ klass.refresh!
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ require "rails/railtie"
2
+
3
+ module SearchCraft
4
+ class Railtie < Rails::Railtie
5
+ initializer "searchcraft.reloader_hook" do
6
+ ActiveSupport::Reloader.to_prepare do
7
+ next unless SearchCraft.database_ready?
8
+ next unless SearchCraft.config.autorebuild?
9
+ next unless SearchCraft.dependencies_ready?
10
+
11
+ warn "[#{Rails.env}] running: SearchCraft::Builder.rebuild_any_if_changed!" if SearchCraft.debug?
12
+
13
+ SearchCraft::Builder.rebuild_any_if_changed!
14
+ rescue => e
15
+ if SearchCraft.debug?
16
+ puts "Preparing SearchCraft: #{e.message}"
17
+ puts e.backtrace
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchCraft
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,61 @@
1
+ # Stores the sha265 hash of the SQL used to create each view.
2
+ # This allows the Builder to determine if a view needs
3
+ # to be recreated. We don't want to use ActiveRecord Migrations,
4
+ # instead want the view to be automatically recreated when
5
+ # any Builder's #view_select_sql SQL changes.
6
+ #
7
+ # The view SQL hashes are stored in a table named
8
+ # search_craft_view_hash_stores, which is automatically
9
+ # created by SearchCraft.
10
+ class SearchCraft::ViewHashStore < ActiveRecord::Base
11
+ self.table_name = "search_craft_view_hash_stores"
12
+
13
+ class << self
14
+ def update_for(builder:)
15
+ setup_table_if_needed!
16
+ view_sql_hash = builder.view_sql_hash
17
+ view_hash_store = find_or_initialize_by(view_name: builder.view_name)
18
+ view_hash_store.update!(view_sql_hash: view_sql_hash)
19
+ end
20
+
21
+ def changed?(builder:)
22
+ setup_table_if_needed!
23
+
24
+ view_sql_hash = builder.view_sql_hash
25
+ view_hash_store = exists?(builder: builder)
26
+ !view_hash_store || view_hash_store.view_sql_hash != view_sql_hash
27
+ end
28
+
29
+ def exists?(builder:)
30
+ setup_table_if_needed!
31
+ connection.schema_cache.data_source_exists?(builder.view_name) &&
32
+ find_by(view_name: builder.view_name)
33
+ end
34
+
35
+ def reset!(builder:)
36
+ view_hash_store = find_by(view_name: builder.view_name)
37
+ view_hash_store&.destroy!
38
+ end
39
+
40
+ def setup_table_if_needed!
41
+ return if table_exists?
42
+
43
+ # TODO: store its own sha265 hash in a table,
44
+ # so we can detect if it changes during development
45
+ # and recreate table if table is empty -- just in case
46
+
47
+ # Migrate table
48
+ create_table_sql = <<~SQL
49
+ CREATE TABLE search_craft_view_hash_stores (
50
+ id serial primary key,
51
+ view_name varchar(255) not null,
52
+ view_sql_hash varchar(255) not null,
53
+ created_at timestamp not null default now(),
54
+ updated_at timestamp not null default now()
55
+ );
56
+ SQL
57
+ ActiveRecord::Base.connection.execute(create_table_sql)
58
+ reset_column_information
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchCraft
4
+ class Error < StandardError; end
5
+
6
+ extend self
7
+
8
+ def configure
9
+ yield(config)
10
+ end
11
+
12
+ def config
13
+ @config ||= Configuration.new
14
+ end
15
+
16
+ def database_ready?
17
+ ActiveRecord::Base.connection.table_exists?("schema_migrations")
18
+ rescue
19
+ false
20
+ end
21
+
22
+ def dependencies_ready?
23
+ config.explicit_builder_class_names.all? do |builder_class_name|
24
+ builder_class_name.constantize.new.dependencies_ready?
25
+ end
26
+ end
27
+
28
+ def debug?
29
+ config.debug
30
+ end
31
+
32
+ def load_tasks
33
+ return if @tasks_loaded
34
+
35
+ Dir[File.join(File.dirname(__FILE__), "tasks", "**/*.rake")].each do |rake|
36
+ load rake
37
+ end
38
+
39
+ @tasks_loaded = true
40
+ end
41
+ end
42
+
43
+ require "active_record"
44
+
45
+ require_relative "searchcraft/version"
46
+ require_relative "searchcraft/configuration"
47
+ require_relative "searchcraft/annotate"
48
+ require_relative "searchcraft/depends_on"
49
+ require_relative "searchcraft/dump_schema"
50
+ require_relative "searchcraft/builder"
51
+ require_relative "searchcraft/model"
52
+ require_relative "searchcraft/view_hash_store"
53
+ require_relative "searchcraft/railtie" if defined?(Rails)
@@ -0,0 +1,25 @@
1
+ namespace :searchcraft do
2
+ desc "Recreates search builders' materialized views if necessary"
3
+ task rebuild: :environment do
4
+ puts "Rebuilding search builders' materialized views if necessary"
5
+ puts Benchmark.measure {
6
+ SearchCraft::Builder.rebuild_any_if_changed!
7
+ }
8
+ end
9
+
10
+ desc "Recreates all materialized views' indices"
11
+ task recreate_indexes: :environment do
12
+ puts "Recreating search builders' indices"
13
+ puts Benchmark.measure {
14
+ SearchCraft::Builder.recreate_indexes!
15
+ }
16
+ end
17
+
18
+ desc "Force recreate all materialize views, including indexes and sequences"
19
+ task force_recreate: :environment do
20
+ puts "Force recreateing search builders' materialized views"
21
+ puts Benchmark.measure {
22
+ SearchCraft::Builder.rebuild_all!
23
+ }
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ namespace :searchcraft do
2
+ desc "Refresh searchcraft materialized views"
3
+ task refresh: :environment do
4
+ SearchCraft::Builder.rebuild_any_if_changed!
5
+ require "benchmark"
6
+ SearchCraft.config.explicit_model_class_names.each do |model_class_name|
7
+ klass = model_class_name.constantize
8
+ puts "Refreshing materialized views for #{klass.name}"
9
+ puts Benchmark.measure { klass.refresh! }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module SearchCraft
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: searchcraft
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Dr Nic Williams
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-10-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: scenic
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Use your SQL database to power instant search for your Rails app with
98
+ materalized views
99
+ email:
100
+ - drnicwilliams@gmail.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - ".DS_Store"
106
+ - ".overcommit.yml"
107
+ - ".standard.yml"
108
+ - CHANGELOG.md
109
+ - CODE_OF_CONDUCT.md
110
+ - LICENSE.txt
111
+ - README.md
112
+ - Rakefile
113
+ - lib/search_craft.rb
114
+ - lib/searchcraft.rb
115
+ - lib/searchcraft/annotate.rb
116
+ - lib/searchcraft/builder.rb
117
+ - lib/searchcraft/configuration.rb
118
+ - lib/searchcraft/depends_on.rb
119
+ - lib/searchcraft/dump_schema.rb
120
+ - lib/searchcraft/model.rb
121
+ - lib/searchcraft/railtie.rb
122
+ - lib/searchcraft/version.rb
123
+ - lib/searchcraft/view_hash_store.rb
124
+ - lib/tasks/builders.rake
125
+ - lib/tasks/refresh.rake
126
+ - sig/searchcraft.rbs
127
+ homepage: https://github.com/drnic/searchcraft
128
+ licenses:
129
+ - MIT
130
+ metadata:
131
+ allowed_push_host: https://rubygems.org
132
+ homepage_uri: https://github.com/drnic/searchcraft
133
+ post_install_message:
134
+ rdoc_options: []
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '3.0'
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ requirements: []
148
+ rubygems_version: 3.4.20
149
+ signing_key:
150
+ specification_version: 4
151
+ summary: Instant search for Rails and ActiveRecord
152
+ test_files: []