├── .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 | Datasource talk
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 | --------------------------------------------------------------------------------