searchcraft 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []