├── .gitignore
├── .ruby-gemset
├── .ruby-version
├── CHANGES.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── active_loaders.gemspec
├── lib
├── active_loaders.rb
└── active_loaders
│ ├── datasource_adapter.rb
│ ├── test.rb
│ └── version.rb
└── spec
├── sequel_serializer_spec.rb
├── sequel_skip_select_spec.rb
├── serializer_spec.rb
├── skip_select_spec.rb
├── spec_helper.rb
├── support
└── db.rb
└── test_methods_spec.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | .bundle
4 | .config
5 | .yardoc
6 | Gemfile.lock
7 | InstalledFiles
8 | _yardoc
9 | coverage
10 | doc/
11 | lib/bundler/man
12 | pkg
13 | rdoc
14 | spec/reports
15 | test/tmp
16 | test/version_tmp
17 | tmp
18 | *.bundle
19 | *.so
20 | *.o
21 | *.a
22 | mkmf.log
23 |
--------------------------------------------------------------------------------
/.ruby-gemset:
--------------------------------------------------------------------------------
1 | active_loaders
2 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 2.2.0
2 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 | ### 0.0.1
2 |
3 | - the following are changes from what was previously in the datasource gem
4 | - change Serializer datasource_select method to loaders { select(...) }
5 | - change Serializer datasource_includes method to loaders { includes(...) }
6 | - render method: rename datasource_params to loader_params
7 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | # Specify your gem's dependencies in active_loaders.gemspec
4 | gemspec
5 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 Jan Berdajs
2 |
3 | MIT License
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ActiveLoaders
2 |
3 | - Automatically preload associations for your serializers
4 | - Specify custom SQL snippets for virtual attributes (Query attributes)
5 | - Write custom preloading logic in a reusable way
6 |
7 | *Note: the API of this gem is still unstable and may change between versions. This project uses semantic versioning, however until version 1.0.0, minor version (MAJOR.MINOR.PATCH) changes may include API changes, but patch version will not)*
8 |
9 | 
A 30-min talk about Datasource
12 |
13 | #### Install
14 |
15 | Ruby version requirement:
16 |
17 | - MRI 2.0 or higher
18 | - JRuby 9000
19 |
20 | Supported ORM:
21 |
22 | - ActiveRecord
23 | - Sequel
24 |
25 | Add to Gemfile (recommended to use github version until API is stable)
26 |
27 | ```
28 | gem 'active_loaders', github: 'kundi/active_loaders'
29 | ```
30 |
31 | ```
32 | bundle install
33 | rails g datasource:install
34 | ```
35 |
36 | #### Upgrade
37 |
38 | ```
39 | rails g datasource:install
40 | ```
41 |
42 | ### Introduction
43 |
44 | The most important role of ActiveLoaders is to help prevent and fix the
45 | [N+1 queries problem](http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations)
46 | when using Active Model Serializers.
47 |
48 | This gem depends on the datasource gem that handles actual data loading. What this gem
49 | adds on top of it is integration with Active Model Serializers. It will automatically
50 | read your serializers to make datasource preload the necessary associations. Additionally
51 | it provides a simple DSL to configure additional dependencies and test helpers to ensure
52 | your queries are optimized.
53 |
54 | ActiveLoaders will automatically recognize associations in your **serializer** when you use
55 | the `has_many` or `belongs_to` keywords:
56 |
57 | ```ruby
58 | class PostSerializer < ActiveModel::Serializer
59 | belongs_to :blog
60 | has_many :comments
61 | end
62 | ```
63 |
64 | In this case, it will then look in your BlogSerializer and CommentSerializer to properly
65 | load them as well (so it is recursive).
66 |
67 | When you are using loaded values (explained below), ActiveLoaders will automatically
68 | use them if you specify the name in `attributes`. For example if you have a
69 | `loaded :comment_count` it will automatically be used if you have
70 | `attributes :comment_count` in your serializer.
71 |
72 | In case ActiveLoaders doesn't automatically detect something, you can always manually
73 | specify it in your serializer using a simple DSL.
74 |
75 | A test helper is also provided which you can ensure that your serializers don't produce
76 | N+1 queries.
77 |
78 | ### Associations
79 |
80 | The most noticable magic effect of using ActiveLoaders is that associations will
81 | automatically be preloaded using a single query.
82 |
83 | ```ruby
84 | class PostSerializer < ActiveModel::Serializer
85 | attributes :id, :title
86 | end
87 |
88 | class UserSerializer < ActiveModel::Serializer
89 | attributes :id
90 | has_many :posts
91 | end
92 | ```
93 | ```sql
94 | SELECT users.* FROM users
95 | SELECT posts.* FROM posts WHERE id IN (?)
96 | ```
97 |
98 | This means you **do not** need to call `includes` yourself. It will be done
99 | automatically.
100 |
101 | #### Manually include
102 |
103 | In case you are not using `has_many` or `belongs_to` in your serializer but you are
104 | still using the association (usually when you do not embed the association), then you
105 | need to manually specify this in your serializer. There are two options depending on
106 | what data you need.
107 |
108 | **includes**: use this when you just need a simple `includes`, which behaves the same
109 | as in ActiveRecord.
110 |
111 | ```ruby
112 | class UserSerializer < ActiveModel::Serializer
113 | attributes :id, :post_titles
114 | loaders do
115 | includes :posts
116 | # includes posts: { :comments }
117 | end
118 |
119 | def post_titles
120 | object.posts.map(&:title)
121 | end
122 | end
123 | ```
124 |
125 | **select**: use this to use the serializer loading logic - the same recursive logic that
126 | happens when you use `has_many` or `belongs_to`. This will also load associations and
127 | loaded values (unless otherwise specified).
128 |
129 |
130 | ```ruby
131 | class UserSerializer < ActiveModel::Serializer
132 | attributes :id, :comment_loaded_values
133 | loaders do
134 | select :posts
135 | # select posts: [:id, comments: [:id, :some_loaded_value]]
136 | end
137 |
138 | def comment_loaded_values
139 | object.posts.flat_map(&:comments).map(&:some_loaded_value)
140 | end
141 | end
142 |
143 | class PostSerializer < ActiveModel::Serializer
144 | attributes :id
145 | has_many :comments
146 | end
147 |
148 | class CommentSerializer < ActiveModel::Serializer
149 | attributes :id, :some_loaded_value
150 | end
151 | ```
152 |
153 | ### Query attribute
154 |
155 | You can specify a SQL fragment for `SELECT` and use that as an attribute on your
156 | model. This is done through the datasource gem DSL. As a simple example you can
157 | concatenate 2 strings together in SQL:
158 |
159 | ```ruby
160 | class User < ActiveRecord::Base
161 | datasource_module do
162 | query :full_name do
163 | "users.first_name || ' ' || users.last_name"
164 | end
165 | end
166 | end
167 |
168 | class UserSerializer < ActiveModel::Serializer
169 | attributes :id, :full_name
170 | end
171 | ```
172 |
173 | ```sql
174 | SELECT users.*, (users.first_name || ' ' || users.last_name) AS full_name FROM users
175 | ```
176 |
177 | Note: If you need data from another table, use a loaded value.
178 |
179 | ### Refactor with standalone Datasource class
180 |
181 | If you are going to have more complex preloading logic (like using Loaded below),
182 | then it might be better to put Datasource code into its own class. This is pretty
183 | easy, just create a directory `app/datasources` (or whatever you like), and create
184 | a file depending on your model name, for example for a `Post` model, create
185 | `post_datasource.rb`. The name is important for auto-magic reasons. Example file:
186 |
187 | ```ruby
188 | class PostDatasource < Datasource::From(Post)
189 | query(:full_name) { "users.first_name || ' ' || users.last_name" }
190 | end
191 | ```
192 |
193 | This is completely equivalent to using `datasource_module` in your model:
194 |
195 | ```ruby
196 | class Post < ActiveRecord::Base
197 | datasource_module do
198 | query(:full_name) { "users.first_name || ' ' || users.last_name" }
199 | end
200 | end
201 | ```
202 |
203 | ### Loaded
204 |
205 | You might want to have some more complex preloading logic. In that case you can
206 | use a method to load values for all the records at once (e.g. with a custom query
207 | or even from a cache). The loading methods are only executed if you use the values,
208 | otherwise they will be skipped.
209 |
210 | First just declare that you want to have a loaded attribute (the parameters will be explained shortly):
211 |
212 | ```ruby
213 | class UserDatasource < Datasource::From(User)
214 | loaded :post_count, from: :array, default: 0
215 | end
216 | ```
217 |
218 | By default, datasource will look for a method named `load_` for loading
219 | the values, in this case `load_newest_comment`. It needs to be defined in the
220 | collection block, which has methods to access information about the collection (posts)
221 | that are being loaded. These methods are `scope`, `models`, `model_ids`,
222 | `datasource`, `datasource_class` and `params`.
223 |
224 | ```ruby
225 | class UserDatasource < Datasource::From(User)
226 | loaded :post_count, from: :array, default: 0
227 |
228 | collection do
229 | def load_post_count
230 | Post.where(user_id: model_ids)
231 | .group(:user_id)
232 | .pluck("user_id, COUNT(id)")
233 | end
234 | end
235 | end
236 | ```
237 |
238 | In this case `load_post_count` returns an array of pairs.
239 | For example: `[[1, 10], [2, 5]]`. Datasource can understand this because of
240 | `from: :array`. This would result in the following:
241 |
242 | ```ruby
243 | post_id_1.post_count # => 10
244 | post_id_2.post_count # => 5
245 | # other posts will have the default value or nil if no default value was given
246 | other_post.post_count # => 0
247 | ```
248 |
249 | Besides `default` and `from: :array`, you can also specify `group_by`, `one`
250 | and `source`. Source is just the name of the load method.
251 |
252 | The other two are explained in the following example.
253 |
254 | ```ruby
255 | class PostDatasource < Datasource::From(Post)
256 | loaded :newest_comment, group_by: :post_id, one: true, source: :load_newest_comment
257 |
258 | collection do
259 | def load_newest_comment
260 | Comment.for_serializer.where(post_id: model_ids)
261 | .group("post_id")
262 | .having("id = MAX(id)")
263 | end
264 | end
265 | end
266 | ```
267 |
268 | In this case the load method returns an ActiveRecord relation, which for our purposes
269 | acts the same as an Array (so we could also return an Array if we wanted).
270 | Using `group_by: :post_id` in the `loaded` call tells datasource to group the
271 | results in this array by that attribute (or key if it's an array of hashes instead
272 | of model objects). `one: true` means that we only want a single value instead of
273 | an array of values (we might want multiple, e.g. `newest_10_comments`).
274 | So in this case, if we had a Post with id 1, `post.newest_comment` would be a
275 | Comment from the array that has `post_id` equal to 1.
276 |
277 | In this case, in the load method, we also used `for_serializer`, which will load
278 | the `Comment`s according to the `CommentSerializer`.
279 |
280 | Note that it's perfectly fine (even recommended) to already have a method with the same
281 | name in your model.
282 | If you use that method outside of serializers/datasource, it will work just as
283 | it should. But when using datasource, it will be overwritten by the datasource
284 | version. Counts is a good example:
285 |
286 | ```ruby
287 | class User < ActiveRecord::Base
288 | has_many :posts
289 |
290 | def post_count
291 | posts.count
292 | end
293 | end
294 |
295 | class UserDatasource < Datasource::From(User)
296 | loaded :post_count, from: :array, default: 0
297 |
298 | collection do
299 | def load_post_count
300 | Post.where(user_id: model_ids)
301 | .group(:user_id)
302 | .pluck("user_id, COUNT(id)")
303 | end
304 | end
305 | end
306 |
307 | class UserSerializer < ActiveModel::Serializer
308 | attributes :id, :post_count # <- post_count will be read from load_post_count
309 | end
310 |
311 | User.first.post_count # <- your model method will be called
312 | ```
313 |
314 | ### Params
315 |
316 | You can also specify params that can be read from collection methods. The params
317 | can be specified when you call `render`:
318 |
319 | ```ruby
320 | # controller
321 | render json: posts,
322 | loader_params: { include_newest_comments: true }
323 |
324 | # datasource
325 | loaded :newest_comments, default: []
326 |
327 | collection do
328 | def load_newest_comments
329 | if params[:include_newest_comments]
330 | # ...
331 | end
332 | end
333 | end
334 | ```
335 |
336 | ### Debugging and logging
337 |
338 | Datasource outputs some useful logs that you can use debugging. By default the log level is
339 | set to warnings only, but you can change it. You can add the following line at the end of your
340 | `config/initializers/datasource.rb`:
341 |
342 | ```ruby
343 | Datasource.logger.level = Logger::INFO unless Rails.env.production?
344 | ```
345 |
346 | You can also set it to `DEBUG` for more output. The logger outputs to `stdout` by default. It
347 | is not recommended to have this enabled in production (simply for performance reasons).
348 |
349 | ### Using manually
350 |
351 | When using a serializer, ActiveLoaders should work automatically. If for some reason
352 | you want to manually trigger loaders on a scope, you can call `for_serializer`.
353 |
354 | ```ruby
355 | Post.for_serializer.find(params[:id])
356 | Post.for_serializer(PostSerializer).find(params[:id])
357 | Post.for_serializer.where("created_at > ?", 1.day.ago).to_a
358 | ```
359 |
360 | You can also use it on an existing record, but you must use the returned value (the record
361 | may be reloaded e.g. if you are using query attributes).
362 |
363 | ```ruby
364 | user = current_user.for_serializer
365 | ```
366 |
367 | For even more advanced usage, see Datasource gem documentation.
368 |
369 | ### Testing your serializer queries
370 |
371 | ActiveLoaders provides test helpers to make sure your queries stay optimized. By default
372 | it expects there to be no N+1 queries, so after the initial loading of the records and
373 | associations, there should be no queries from code in the serializers. The helpers raise
374 | and error otherwise, so you can use them with any testing framework (rspec, minitest).
375 | You need to put some records into the database before calling the helper, since it is
376 | required to be able to test the serializer.
377 |
378 | ```ruby
379 | test_serializer_queries(serializer_class, model_class, options = {})
380 | ```
381 |
382 | Here is a simple example in rspec with factory_girl:
383 |
384 | ```ruby
385 | require 'spec_helper'
386 | require 'active_loaders/test'
387 |
388 | context "serializer queries" do
389 | include ActiveLoaders::Test
390 | let(:blog) { create :blog }
391 | before do
392 | 2.times {
393 | create :post, blog_id: blog.id
394 | }
395 | end
396 |
397 | it "should not contain N+1 queries" do
398 | expect { test_serializer_queries(BlogSerializer, Blog) }.to_not raise_error
399 | end
400 |
401 | # example if you have N+1 queries and you can't avoid them
402 | it "should contain exactly two N+1 queries (two queries for every Blog)" do
403 | expect { test_serializer_queries(BlogSerializer, Blog, allow_queries_per_record: 2) }.to_not raise_error
404 | end
405 | end
406 | ```
407 |
408 | #### Columns check
409 |
410 | Recently (not yet released as of Rails 4.2), an `accessed_fields` instance method
411 | was added to ActiveRecord models. ActiveLoaders can use this information in your
412 | tests to determine which attributes you are not using in your serializer. This check
413 | is skipped if your Rails version doesn't support `accessed_fields`.
414 |
415 | Let's say your are not using User#payment_data in your serializer. You have this test:
416 |
417 | ```ruby
418 | it "should not contain N+1 queries" do
419 | expect { test_serializer_queries(UserSerializer, User) }.to_not raise_error
420 | end
421 | ```
422 |
423 | Then this test will fail with instructions on how to fix it:
424 |
425 | ```ruby
426 | ActiveLoaders::Test::Error:
427 | unnecessary select for User columns: payment_data
428 |
429 | Add to UserSerializer loaders block:
430 | skip_select :payment_data
431 |
432 | Or ignore this error with:
433 | test_serializer_queries(UserSerializer, User, ignore_columns: [:payment_data])
434 |
435 | Or skip this columns check entirely:
436 | test_serializer_queries(UserSerializer, User, skip_columns_check: true)
437 | ```
438 |
439 | The instructions should be self-explanatory. Choosing the first option:
440 |
441 | ```ruby
442 | class UserSerializer < ActiveModel::Serializer
443 | attributes :id, :title
444 |
445 | loaders do
446 | skip_select :payment_data
447 | end
448 | end
449 | ```
450 |
451 | Would then produce an optimized query:
452 | ```sql
453 | SELECT users.id, users.title FROM users
454 | ```
455 |
456 | ## Getting Help
457 |
458 | If you find a bug, please report an [Issue](https://github.com/kundi/active_loaders/issues/new).
459 |
460 | If you have a question, you can also open an Issue.
461 |
462 | ## Contributing
463 |
464 | 1. Fork it ( https://github.com/kundi/active_loaders/fork )
465 | 2. Create your feature branch (`git checkout -b my-new-feature`)
466 | 3. Commit your changes (`git commit -am 'Add some feature'`)
467 | 4. Push to the branch (`git push origin my-new-feature`)
468 | 5. Create a new Pull Request
469 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 |
3 |
--------------------------------------------------------------------------------
/active_loaders.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'active_loaders/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "active_loaders"
8 | spec.version = ActiveLoaders::VERSION
9 | spec.authors = ["Jan Berdajs"]
10 | spec.email = ["mrbrdo@gmail.com"]
11 | spec.summary = %q{Ruby library to automatically preload data for your Active Model Serializers}
12 | spec.homepage = "https://github.com/kundi/active_loaders"
13 | spec.license = "MIT"
14 |
15 | spec.files = `git ls-files -z`.split("\x0")
16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18 | spec.require_paths = ["lib"]
19 |
20 | spec.add_dependency 'active_model_serializers', '~> 0.9'
21 | spec.add_dependency 'datasource', '~> 0.3'
22 | spec.add_development_dependency "bundler", "~> 1.6"
23 | spec.add_development_dependency "rake"
24 | spec.add_development_dependency "rspec", "~> 3.2"
25 | spec.add_development_dependency 'sqlite3', '~> 1.3'
26 | spec.add_development_dependency 'activerecord', '~> 4'
27 | spec.add_development_dependency 'pry', '~> 0.9'
28 | spec.add_development_dependency 'sequel', '~> 4.17'
29 | spec.add_development_dependency 'database_cleaner', '~> 1.3'
30 | end
31 |
--------------------------------------------------------------------------------
/lib/active_loaders.rb:
--------------------------------------------------------------------------------
1 | require "active_loaders/version"
2 | require "active_loaders/datasource_adapter"
3 |
4 | module ActiveLoaders
5 |
6 | end
7 |
--------------------------------------------------------------------------------
/lib/active_loaders/datasource_adapter.rb:
--------------------------------------------------------------------------------
1 | require "active_model/serializer"
2 | require "datasource"
3 |
4 | module ActiveLoaders
5 | module Adapters
6 | module ActiveModelSerializers
7 | module ArraySerializer
8 | def initialize_with_loaders(objects, options = {})
9 | datasource_class = options.delete(:datasource)
10 | adapter = Datasource.orm_adapters.find { |a| a.is_scope?(objects) }
11 | if adapter && !adapter.scope_loaded?(objects)
12 | scope = begin
13 | objects
14 | .for_serializer(options[:serializer])
15 | .datasource_params(*[options[:loader_params]].compact)
16 | rescue NameError
17 | if options[:serializer].nil?
18 | return initialize_without_loaders(objects, options)
19 | else
20 | raise
21 | end
22 | end
23 |
24 | if datasource_class
25 | scope = scope.with_datasource(datasource_class)
26 | end
27 |
28 | records = adapter.scope_to_records(scope)
29 |
30 | # if we are loading an association proxy, we should set the target
31 | # especially because AMS will resolve it twice, which would do 2 queries
32 | if objects.respond_to?(:proxy_association) && objects.proxy_association
33 | objects.proxy_association.target = records
34 | end
35 |
36 | initialize_without_loaders(records, options)
37 | else
38 | initialize_without_loaders(objects, options)
39 | end
40 | end
41 | end
42 |
43 | module_function
44 | def get_serializer_for(klass, serializer_assoc = nil)
45 | serializer = if serializer_assoc
46 | if serializer_assoc.kind_of?(Hash)
47 | serializer_assoc[:options].try(:[], :serializer)
48 | else
49 | serializer_assoc.options[:serializer]
50 | end
51 | end
52 | serializer || "#{klass.name}Serializer".constantize
53 | end
54 |
55 | def to_datasource_select(result, klass, serializer = nil, serializer_assoc = nil, adapter = nil, datasource = nil)
56 | adapter ||= Datasource::Base.default_adapter
57 | serializer ||= get_serializer_for(klass, serializer_assoc)
58 | if serializer._attributes.respond_to?(:keys) # AMS 0.8
59 | result.concat(serializer._attributes.keys)
60 | else # AMS 0.9
61 | result.concat(serializer._attributes)
62 | end
63 | result.concat(serializer.loaders_context.select)
64 | if serializer.loaders_context.skip_select.empty?
65 | result.unshift("*")
66 | else
67 | datasource_class = if datasource
68 | datasource.class
69 | else
70 | serializer.use_datasource || klass.default_datasource
71 | end
72 | result.concat(datasource_class._column_attribute_names -
73 | serializer.loaders_context.skip_select.map(&:to_s))
74 | end
75 | result_assocs = serializer.loaders_context.includes.dup
76 | result.push(result_assocs)
77 |
78 | serializer._associations.each_pair do |name, serializer_assoc|
79 | # TODO: what if assoc is renamed in serializer?
80 | reflection = adapter.association_reflection(klass, name.to_sym)
81 | assoc_class = reflection[:klass]
82 |
83 | name = name.to_s
84 | result_assocs[name] = []
85 | to_datasource_select(result_assocs[name], assoc_class, nil, serializer_assoc, adapter)
86 | end
87 | rescue Exception => ex
88 | if ex.is_a?(SystemStackError) || ex.is_a?(Datasource::RecursionError)
89 | fail Datasource::RecursionError, "recursive association (involving #{klass.name})"
90 | else
91 | raise
92 | end
93 | end
94 | end
95 | end
96 | end
97 |
98 | module SerializerClassMethods
99 | class SerializerDatasourceContext
100 | def initialize(serializer)
101 | @serializer = serializer
102 | end
103 |
104 | def select(*args)
105 | @datasource_select ||= []
106 | @datasource_select.concat(args)
107 |
108 | @datasource_select
109 | end
110 |
111 | def skip_select(*args)
112 | @datasource_skip_select ||= []
113 | @datasource_skip_select.concat(args)
114 |
115 | @datasource_skip_select
116 | end
117 |
118 | def includes(*args)
119 | @datasource_includes ||= {}
120 |
121 | args.each do |arg|
122 | @datasource_includes.deep_merge!(datasource_includes_to_select(arg))
123 | end
124 |
125 | @datasource_includes
126 | end
127 |
128 | def use_datasource(*args)
129 | @serializer.use_datasource(*args)
130 | end
131 |
132 | private
133 | def datasource_includes_to_select(arg)
134 | if arg.kind_of?(Hash)
135 | arg.keys.inject({}) do |memo, key|
136 | memo[key.to_sym] = ["*", datasource_includes_to_select(arg[key])]
137 | memo
138 | end
139 | elsif arg.kind_of?(Array)
140 | arg.inject({}) do |memo, element|
141 | memo.deep_merge!(datasource_includes_to_select(element))
142 | end
143 | elsif arg.respond_to?(:to_sym)
144 | { arg.to_sym => ["*"] }
145 | else
146 | fail Datasource::Error, "unknown includes value type #{arg.class}"
147 | end
148 | end
149 | end
150 |
151 | def inherited(base)
152 | select_values = loaders_context.select.deep_dup
153 | skip_select_values = loaders_context.skip_select.deep_dup
154 | includes_values = loaders_context.includes.deep_dup
155 | base.loaders do
156 | select(*select_values)
157 | skip_select(*skip_select_values)
158 | @datasource_includes = includes_values
159 | end
160 | base.use_datasource(use_datasource)
161 |
162 | super
163 | end
164 |
165 | def loaders_context
166 | @loaders_context ||= SerializerDatasourceContext.new(self)
167 | end
168 |
169 | def loaders(&block)
170 | loaders_context.instance_eval(&block)
171 | end
172 |
173 | # required by datasource gem
174 | def datasource_adapter
175 | ActiveLoaders::Adapters::ActiveModelSerializers
176 | end
177 |
178 | # required by datasource gem
179 | def use_datasource(*args)
180 | @use_datasource = args.first unless args.empty?
181 | @use_datasource
182 | end
183 | end
184 |
185 | module SerializerInstanceMethods
186 | def initialize(object, options={}, *args)
187 | if object && object.respond_to?(:for_serializer)
188 | # single record
189 | datasource_class = options.delete(:datasource)
190 | record = object.for_serializer(self.class, datasource_class) do |scope|
191 | scope.datasource_params(*[options[:loader_params]].compact)
192 | end
193 | super(record, options, *args)
194 | else
195 | super
196 | end
197 | end
198 | end
199 |
200 | array_serializer_class = if defined?(ActiveModel::Serializer::ArraySerializer)
201 | ActiveModel::Serializer::ArraySerializer
202 | else
203 | ActiveModel::ArraySerializer
204 | end
205 |
206 | array_serializer_class.class_exec do
207 | alias_method :initialize_without_loaders, :initialize
208 | include ActiveLoaders::Adapters::ActiveModelSerializers::ArraySerializer
209 | def initialize(*args)
210 | initialize_with_loaders(*args)
211 | end
212 | end
213 |
214 | ActiveModel::Serializer.singleton_class.send :prepend, SerializerClassMethods
215 | ActiveModel::Serializer.send :prepend, SerializerInstanceMethods
216 | Datasource::Base.default_consumer_adapter ||= ActiveLoaders::Adapters::ActiveModelSerializers
217 |
--------------------------------------------------------------------------------
/lib/active_loaders/test.rb:
--------------------------------------------------------------------------------
1 | require 'set'
2 |
3 | module ActiveLoaders
4 | module Test
5 | Error = Class.new(StandardError)
6 | def test_serializer_queries(serializer_klass, model_klass, ignore_columns: [], skip_columns_check: false, allow_queries_per_record: 0)
7 | records = get_all_records(model_klass, serializer_klass)
8 | fail "Not enough records to test #{serializer_klass}. Create at least 1 #{model_klass}." unless records.size > 0
9 |
10 | records.each do |record|
11 | queries = get_executed_queries do
12 | serializer_klass.new(record).as_json
13 | end
14 |
15 | unless queries.size == allow_queries_per_record
16 | fail Error, "unexpected queries\n\nRecord:\n#{record.inspect}\n\nQueries:\n#{queries.join("\n")}"
17 | end
18 | end
19 |
20 | # just for good measure
21 | queries = get_executed_queries do
22 | ActiveModel::ArraySerializer.new(records, each_serializer: serializer_klass).as_json
23 | end
24 | unless queries.size == (records.size * allow_queries_per_record)
25 | fail Error, "unexpected queries when using ArraySerializer\n\nModel:\n#{model_klass}\n\nQueries:\n#{queries.join("\n")}"
26 | end
27 |
28 | # select values (if supported)
29 | # TODO: Sequel?
30 | unless skip_columns_check
31 | if defined?(ActiveRecord::Base) && model_klass.ancestors.include?(ActiveRecord::Base)
32 | if records.first.respond_to?(:accessed_fields)
33 | accessed_fields = Set.new
34 | records.each { |record| accessed_fields.merge(record.accessed_fields) }
35 |
36 | unaccessed_columns = model_klass.column_names - accessed_fields.to_a - ignore_columns.map(&:to_s)
37 |
38 | unless unaccessed_columns.empty?
39 | unaccessed_columns_str = unaccessed_columns.join(", ")
40 | unaccessed_columns_syms = unaccessed_columns.map { |c| ":#{c}" }.join(", ")
41 | all_unaccessed_columns_syms = (ignore_columns.map(&:to_s) + unaccessed_columns).map { |c| ":#{c}" }.join(", ")
42 | fail Error, "unnecessary select for #{model_klass} columns: #{unaccessed_columns_str}\n\nAdd to #{serializer_klass} loaders block:\n skip_select #{unaccessed_columns_syms}\n\nOr ignore this error with:\n test_serializer_queries(#{serializer_klass}, #{model_klass}, ignore_columns: [#{all_unaccessed_columns_syms}])\n\nOr skip this columns check entirely:\n test_serializer_queries(#{serializer_klass}, #{model_klass}, skip_columns_check: true)"
43 | end
44 | end
45 | end
46 | end
47 |
48 | (@active_loaders_tested_serializers ||= Set.new).add(serializer_klass)
49 | end
50 |
51 | def assert_all_serializers_tested(namespace = nil)
52 | descendants =
53 | ObjectSpace.each_object(Class)
54 | .select { |klass| klass < ActiveModel::Serializer }
55 | .select { |klass| (namespace.nil? && !klass.name.include?("::")) || klass.name.starts_with?("#{namespace}::") }
56 | .reject { |klass| Array(@active_loaders_tested_serializers).include?(klass) }
57 |
58 | unless descendants.empty?
59 | fail Error, "serializers not tested: #{descendants.map(&:name).join(", ")}"
60 | end
61 | end
62 |
63 | private
64 | def get_all_records(model_klass, serializer_klass)
65 | if defined?(ActiveRecord::Base) && model_klass.ancestors.include?(ActiveRecord::Base)
66 | model_klass.for_serializer(serializer_klass).to_a
67 | elsif defined?(Sequel::Model) && model_klass.ancestors.include?(Sequel::Model)
68 | model_klass.for_serializer(serializer_klass).all
69 | else
70 | fail "Unknown model #{model_klass} of type #{model_klass.superclass}."
71 | end
72 | end
73 |
74 | def get_executed_queries
75 | logger_io = StringIO.new
76 | logger = Logger.new(logger_io)
77 | logger.formatter = ->(severity, datetime, progname, msg) { "#{msg}\n" }
78 | if defined?(ActiveRecord::Base)
79 | ar_old_logger = ActiveRecord::Base.logger
80 | ActiveRecord::Base.logger = logger
81 | end
82 | if defined?(Sequel::Model)
83 | Sequel::Model.db.loggers << logger
84 | end
85 |
86 | begin
87 | yield
88 | ensure
89 | if defined?(ActiveRecord::Base)
90 | ActiveRecord::Base.logger = ar_old_logger
91 | end
92 | if defined?(Sequel::Model)
93 | Sequel::Model.db.loggers.delete(logger)
94 | end
95 | end
96 |
97 | logger_io.string.lines.reject { |line| line.strip == "" }
98 | end
99 | end
100 | end
101 |
--------------------------------------------------------------------------------
/lib/active_loaders/version.rb:
--------------------------------------------------------------------------------
1 | module ActiveLoaders
2 | VERSION = "0.0.1"
3 | end
4 |
--------------------------------------------------------------------------------
/spec/sequel_serializer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module SequelSerializerSpec
4 | describe "Serializer (Sequel)", :sequel do
5 | class Comment < Sequel::Model
6 | many_to_one :post
7 | end
8 |
9 | class Post < Sequel::Model
10 | many_to_one :blog
11 | one_to_many :comments
12 |
13 | datasource_module do
14 | query :author_name do
15 | "posts.author_first_name || ' ' || posts.author_last_name"
16 | end
17 | end
18 | end
19 |
20 | class Blog < Sequel::Model
21 | one_to_many :posts
22 | end
23 |
24 | class CommentSerializer < ActiveModel::Serializer
25 | attributes :id, :comment
26 | end
27 |
28 | class PostSerializer < ActiveModel::Serializer
29 | attributes :id, :title, :author_name
30 | has_many :comments, each_serializer: CommentSerializer
31 |
32 | def author_name
33 | object.values[:author_name]
34 | end
35 | end
36 |
37 | class BlogSerializer < ActiveModel::Serializer
38 | attributes :id, :title
39 |
40 | has_many :posts, each_serializer: PostSerializer
41 | end
42 |
43 | it "returns serialized hash" do
44 | blog = Blog.create title: "Blog 1"
45 | post = Post.create blog_id: blog.id, title: "Post 1", author_first_name: "John", author_last_name: "Doe"
46 | Comment.create(post_id: post.id, comment: "Comment 1")
47 | post = Post.create blog_id: blog.id, title: "Post 2", author_first_name: "Maria", author_last_name: "Doe"
48 | Comment.create(post_id: post.id, comment: "Comment 2")
49 | blog = Blog.create title: "Blog 2"
50 |
51 | expected_result = [
52 | {:id =>1, :title =>"Blog 1", :posts =>[
53 | {:id =>1, :title =>"Post 1", :author_name =>"John Doe", comments: [{:id =>1, :comment =>"Comment 1"}]},
54 | {:id =>2, :title =>"Post 2", :author_name =>"Maria Doe", comments: [{:id =>2, :comment =>"Comment 2"}]}
55 | ]},
56 | {:id =>2, :title =>"Blog 2", :posts =>[]}
57 | ]
58 |
59 | expect_query_count(3) do
60 | serializer = ActiveModel::ArraySerializer.new(Blog.where, each_serializer: BlogSerializer)
61 | expect(expected_result).to eq(serializer.as_json)
62 | end
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/spec/sequel_skip_select_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module SequelSkipSelectSpec
4 | describe "skip_select (Sequel)", :sequel do
5 | class Comment < Sequel::Model
6 | many_to_one :post
7 | end
8 |
9 | class Post < Sequel::Model
10 | many_to_one :blog
11 | one_to_many :comments
12 |
13 | datasource_module do
14 | query :author_name do
15 | "posts.author_first_name || ' ' || posts.author_last_name"
16 | end
17 | end
18 | end
19 |
20 | class Blog < Sequel::Model
21 | one_to_many :posts
22 | end
23 |
24 | class CommentSerializer < ActiveModel::Serializer
25 | attributes :id, :comment
26 | end
27 |
28 | class PostSerializer < ActiveModel::Serializer
29 | attributes :id, :title, :author_name
30 | has_many :comments, each_serializer: CommentSerializer
31 |
32 | loaders do
33 | skip_select :author_first_name, :author_last_name
34 | end
35 |
36 | def author_name
37 | object.values[:author_name]
38 | end
39 | end
40 |
41 | class BlogSerializer < ActiveModel::Serializer
42 | attributes :id, :title
43 |
44 | has_many :posts, each_serializer: PostSerializer
45 | end
46 |
47 | it "returns serialized hash" do
48 | blog = Blog.create title: "Blog 1"
49 | post = Post.create blog_id: blog.id, title: "Post 1", author_first_name: "John", author_last_name: "Doe"
50 | Comment.create(post_id: post.id, comment: "Comment 1")
51 | post = Post.create blog_id: blog.id, title: "Post 2", author_first_name: "Maria", author_last_name: "Doe"
52 | Comment.create(post_id: post.id, comment: "Comment 2")
53 | blog = Blog.create title: "Blog 2"
54 |
55 | expected_result = [
56 | {:id =>1, :title =>"Blog 1", :posts =>[
57 | {:id =>1, :title =>"Post 1", :author_name =>"John Doe", comments: [{:id =>1, :comment =>"Comment 1"}]},
58 | {:id =>2, :title =>"Post 2", :author_name =>"Maria Doe", comments: [{:id =>2, :comment =>"Comment 2"}]}
59 | ]},
60 | {:id =>2, :title =>"Blog 2", :posts =>[]}
61 | ]
62 |
63 | expect_query_count(3) do |logger|
64 | serializer = ActiveModel::ArraySerializer.new(Blog.where, each_serializer: BlogSerializer)
65 | expect(expected_result).to eq(serializer.as_json)
66 | expect(logger.string.lines[0]).to include("blogs.*")
67 | expect(logger.string.lines[1]).to_not include("posts.*")
68 | expect(logger.string.lines[1]).to_not include("posts.author_first_name,")
69 | expect(logger.string.lines[1]).to_not include("posts.author_last_name,")
70 | expect(logger.string.lines[1]).to include("posts.id")
71 | expect(logger.string.lines[1]).to include("posts.title")
72 | expect(logger.string.lines[1]).to include("posts.blog_id")
73 | expect(logger.string.lines[2]).to include("comments.*")
74 | end
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/spec/serializer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module SerializerSpec
4 | describe "Serializer", :activerecord do
5 | class Comment < ActiveRecord::Base
6 | belongs_to :post
7 | end
8 |
9 | class Post < ActiveRecord::Base
10 | belongs_to :blog
11 | has_many :comments
12 |
13 | datasource_module do
14 | query :author_name do
15 | "posts.author_first_name || ' ' || posts.author_last_name"
16 | end
17 | end
18 | end
19 |
20 | class Blog < ActiveRecord::Base
21 | has_many :posts
22 | end
23 |
24 | class BlogSerializer < ActiveModel::Serializer
25 | attributes :id, :title
26 |
27 | has_many :posts
28 | end
29 |
30 | class PostSerializer < ActiveModel::Serializer
31 | attributes :id, :title, :author_name
32 |
33 | has_many :comments
34 | end
35 |
36 | class CommentSerializer < ActiveModel::Serializer
37 | attributes :id, :comment
38 | end
39 |
40 | it "returns serialized hash" do
41 | blog = Blog.create! title: "Blog 1"
42 | post = blog.posts.create! title: "Post 1", author_first_name: "John", author_last_name: "Doe"
43 | post.comments.create! comment: "Comment 1"
44 | post = blog.posts.create! title: "Post 2", author_first_name: "Maria", author_last_name: "Doe"
45 | post.comments.create! comment: "Comment 2"
46 | blog = Blog.create! title: "Blog 2"
47 |
48 | expected_result = [
49 | {:id =>1, :title =>"Blog 1", :posts =>[
50 | {:id =>1, :title =>"Post 1", :author_name =>"John Doe", comments: [{:id =>1, :comment =>"Comment 1"}]},
51 | {:id =>2, :title =>"Post 2", :author_name =>"Maria Doe", comments: [{:id =>2, :comment =>"Comment 2"}]}
52 | ]},
53 | {:id =>2, :title =>"Blog 2", :posts =>[]}
54 | ]
55 |
56 | expect_query_count(3) do
57 | serializer = ActiveModel::ArraySerializer.new(Blog.all)
58 | expect(expected_result).to eq(serializer.as_json)
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/spec/skip_select_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module SkipSelectSpec
4 | describe "skip_select", :activerecord do
5 | class Comment < ActiveRecord::Base
6 | belongs_to :post
7 | end
8 |
9 | class Post < ActiveRecord::Base
10 | belongs_to :blog
11 | has_many :comments
12 |
13 | datasource_module do
14 | query :author_name do
15 | "posts.author_first_name || ' ' || posts.author_last_name"
16 | end
17 | end
18 | end
19 |
20 | class Blog < ActiveRecord::Base
21 | has_many :posts
22 | end
23 |
24 | class BlogSerializer < ActiveModel::Serializer
25 | attributes :id, :title
26 |
27 | has_many :posts
28 | end
29 |
30 | class PostSerializer < ActiveModel::Serializer
31 | attributes :id, :title, :author_name
32 |
33 | has_many :comments
34 |
35 | loaders do
36 | skip_select :author_first_name, :author_last_name
37 | end
38 | end
39 |
40 | class CommentSerializer < ActiveModel::Serializer
41 | attributes :id, :comment
42 | end
43 |
44 | it "returns serialized hash" do
45 | blog = Blog.create! title: "Blog 1"
46 | post = blog.posts.create! title: "Post 1", author_first_name: "John", author_last_name: "Doe"
47 | post.comments.create! comment: "Comment 1"
48 | post = blog.posts.create! title: "Post 2", author_first_name: "Maria", author_last_name: "Doe"
49 | post.comments.create! comment: "Comment 2"
50 | blog = Blog.create! title: "Blog 2"
51 |
52 | expected_result = [
53 | {:id =>1, :title =>"Blog 1", :posts =>[
54 | {:id =>1, :title =>"Post 1", :author_name =>"John Doe", comments: [{:id =>1, :comment =>"Comment 1"}]},
55 | {:id =>2, :title =>"Post 2", :author_name =>"Maria Doe", comments: [{:id =>2, :comment =>"Comment 2"}]},
56 | ]},
57 | {:id =>2, :title =>"Blog 2", :posts =>[]}
58 | ]
59 |
60 | expect_query_count(3) do |logger|
61 | serializer = ActiveModel::ArraySerializer.new(Blog.all)
62 | expect(expected_result).to eq(serializer.as_json)
63 | expect(logger.string.lines[0]).to include("blogs.*")
64 | expect(logger.string.lines[1]).to_not include("posts.*")
65 | expect(logger.string.lines[1]).to_not include("posts.author_first_name,")
66 | expect(logger.string.lines[1]).to_not include("posts.author_last_name,")
67 | expect(logger.string.lines[1]).to include("posts.id")
68 | expect(logger.string.lines[1]).to include("posts.title")
69 | expect(logger.string.lines[1]).to include("posts.blog_id")
70 | expect(logger.string.lines[2]).to include("comments.*")
71 | end
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | ENV["RAILS_ENV"] ||= 'test'
2 | require 'rspec/core'
3 | require 'rspec/expectations'
4 | require 'rspec/mocks'
5 | require 'database_cleaner'
6 | require 'pry'
7 |
8 | require 'active_support/all'
9 | require 'active_record'
10 | require 'sequel'
11 | require 'datasource'
12 | require 'active_loaders'
13 | require 'active_loaders/test'
14 | require 'active_model_serializers'
15 |
16 | Datasource.setup do |config|
17 | config.adapters = [:activerecord, :sequel]
18 | config.raise_error_on_unknown_attribute_select = false
19 | end
20 |
21 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
22 |
23 | RSpec.configure do |config|
24 | config.order = "random"
25 |
26 | config.filter_run_including focus: true
27 | config.run_all_when_everything_filtered = true
28 |
29 | config.before(:suite) do
30 | DatabaseCleaner.strategy = :truncation
31 | DatabaseCleaner.clean_with(:truncation)
32 | end
33 |
34 | config.before :each do
35 | DatabaseCleaner.start
36 | end
37 |
38 | config.after :each do
39 | DatabaseCleaner.clean
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/spec/support/db.rb:
--------------------------------------------------------------------------------
1 | db_path = File.expand_path("../../db.sqlite3")
2 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: db_path)
3 | Sequel::Model.db = Sequel.sqlite(db_path)
4 | Sequel::Model.plugin :active_model
5 | ActiveRecord::Migration.verbose = false
6 |
7 | ActiveRecord::Schema.define(:version => 0) do
8 | create_table :blogs, :force => true do |t|
9 | t.string :title
10 | end
11 |
12 | create_table :posts, :force => true do |t|
13 | t.integer :blog_id
14 | t.string :title
15 | t.string :author_first_name
16 | t.string :author_last_name
17 | end
18 |
19 | create_table :comments, :force => true do |t|
20 | t.integer :post_id
21 | t.text :comment
22 | end
23 | end
24 |
25 | Sequel::Model.send :include, ActiveModel::SerializerSupport
26 |
27 | def expect_query_count(count)
28 | logger_io = StringIO.new
29 | logger = Logger.new(logger_io)
30 | logger.formatter = ->(severity, datetime, progname, msg) { "#{msg}\n" }
31 | if defined?(ActiveRecord::Base)
32 | ar_old_logger = ActiveRecord::Base.logger
33 | ActiveRecord::Base.logger = logger
34 | end
35 | if defined?(Sequel::Model)
36 | Sequel::Model.db.loggers << logger
37 | end
38 |
39 | begin
40 | yield(logger_io)
41 | ensure
42 | if defined?(ActiveRecord::Base)
43 | ActiveRecord::Base.logger = ar_old_logger
44 | end
45 | if defined?(Sequel::Model)
46 | Sequel::Model.db.loggers.delete(logger)
47 | end
48 | end
49 |
50 | output = logger_io.string
51 | puts output if output.lines.count != count
52 | expect(logger_io.string.lines.count).to eq(count)
53 | end
54 |
--------------------------------------------------------------------------------
/spec/test_methods_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module TestMethodsSpec
4 | describe "Test Methods" do
5 | include ActiveLoaders::Test
6 |
7 | class Post < ActiveRecord::Base
8 | belongs_to :blog
9 | end
10 | class Blog < ActiveRecord::Base
11 | has_many :posts
12 | end
13 |
14 | class PostSerializer < ActiveModel::Serializer
15 | attributes :id, :title
16 | end
17 |
18 | class BlogSerializer < ActiveModel::Serializer
19 | attributes :id, :title
20 | has_many :posts
21 | end
22 |
23 | class BadBlogSerializer < ActiveModel::Serializer
24 | attributes :id, :title, :stuff
25 |
26 | def stuff
27 | object.posts.to_a
28 | "^^^ I was naughty ^^^"
29 | end
30 | end
31 |
32 | it "should fail when data is not preloaded" do
33 | blog = Blog.create! title: "The Blog"
34 | 2.times do
35 | blog.posts.create! title: "The Post", author_first_name: "John", author_last_name: "Doe", blog_id: 10
36 | end
37 |
38 | expect { test_serializer_queries(BadBlogSerializer, Blog) }.to raise_error(ActiveLoaders::Test::Error)
39 | end
40 |
41 | it "should not fail when data is preloaded" do
42 | blog = Blog.create! title: "The Blog"
43 | 2.times do
44 | blog.posts.create! title: "The Post", author_first_name: "John", author_last_name: "Doe", blog_id: 10
45 | end
46 |
47 | expect { test_serializer_queries(BlogSerializer, Blog) }.to_not raise_error
48 | end
49 |
50 | it "should fail when not all serializers were tested" do
51 | blog = Blog.create! title: "The Blog"
52 |
53 | test_serializer_queries(BlogSerializer, Blog)
54 | expect { assert_all_serializers_tested(TestMethodsSpec) }.to raise_error(ActiveLoaders::Test::Error)
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------