active_loaders 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,6 @@
1
+ require "active_loaders/version"
2
+ require "active_loaders/datasource_adapter"
3
+
4
+ module ActiveLoaders
5
+
6
+ end
@@ -0,0 +1,216 @@
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)
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
+ 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
@@ -0,0 +1,100 @@
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
@@ -0,0 +1,3 @@
1
+ module ActiveLoaders
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,77 @@
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