datasource 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 617cef0cb48d4953a9da29c7476ef583c5d7487e
4
- data.tar.gz: bf76cef2be532f75c71729dbf3fa11fa4022ea37
3
+ metadata.gz: 714357b54441eac8f8eb1c209dad82fa8c588743
4
+ data.tar.gz: 90cb1f1ca000d6683b234c6ccff41425b208be2c
5
5
  SHA512:
6
- metadata.gz: 3d4738bb6e26eae45f63df27fdfe52a7725d49d1e20569eace2af7d71314e20529f3a29fb913979b1f88f960f337418319b2f2dd88ef978ed57d8d72cda5688e
7
- data.tar.gz: 3079481e54532538dd4aeb9df57ec2abe0f412f62369caf91ec3ebe16ab5106036321f3070d02be015972a81da9f6bb087de997f38610ba94c843e9cd4425e95
6
+ metadata.gz: 0414aa6f0f18bdb882d9f54e558ed5301e4c52d77d417cdfd62d51fad0a51f5ebc5191d9ab86a56b6ee69d6c7c489f0c2395c10082b27381e04bfbc70152d3b9
7
+ data.tar.gz: 94f30d58cd6cd8cef946e87f039445910dd924638d00c40a95599d35e473e6eb840a14d4543c4dd405b953633257c3defafb33c777b0581dc2cb724bd05fa4d1
data/README.md CHANGED
@@ -1 +1,212 @@
1
1
  # Datasource
2
+
3
+ Automatically preload your ORM records for your serializer.
4
+
5
+ ## Install
6
+
7
+ Add to Gemfile
8
+
9
+ ```
10
+ gem 'datasource'
11
+ ```
12
+
13
+ And `bundle install`.
14
+
15
+ Run install generator:
16
+
17
+ ```
18
+ rails g datasource:install
19
+ ```
20
+
21
+ ### ORM support
22
+
23
+ - ActiveRecord
24
+ - Sequel
25
+
26
+ ### Serializer support
27
+
28
+ - active_model_serializers
29
+
30
+ ## Basic Usage
31
+
32
+ ### Attributes
33
+ You don't have to do anything special.
34
+
35
+ ```ruby
36
+ class UserSerializer < ActiveModel::Serializer
37
+ attributes :id, :email
38
+ end
39
+ ```
40
+
41
+ But you get an optimized query for free:
42
+
43
+ ```sql
44
+ SELECT id, email FROM users
45
+ ```
46
+
47
+ ### Associations
48
+ You don't have to do anything special.
49
+
50
+ ```ruby
51
+ class PostSerializer < ActiveModel::Serializer
52
+ attributes :id, :title
53
+ end
54
+
55
+ class UserSerializer < ActiveModel::Serializer
56
+ attributes :id
57
+ has_many :posts
58
+ end
59
+ ```
60
+
61
+ But you get automatic association preloading ("includes") with optimized queries for free:
62
+
63
+ ```sql
64
+ SELECT id FROM users
65
+ SELECT id, title, user_id FROM posts WHERE id IN (?)
66
+ ```
67
+
68
+ ### Model Methods / Virtual Attributes
69
+ You need to specify which database columns a method depends on to be able to use it.
70
+ The method itself can be either in the serializer or in your model, it doesn't matter.
71
+
72
+ You can list multiple dependency columns.
73
+
74
+ ```ruby
75
+ class User < ActiveRecord::Base
76
+ datasource_module do
77
+ computed :first_name_initial, :first_name
78
+ computed :last_name_initial, :last_name
79
+ end
80
+
81
+ def first_name_initial
82
+ first_name[0].upcase
83
+ end
84
+ end
85
+
86
+ class UserSerializer < ActiveModel::Serializer
87
+ attributes :first_name_initial, :last_name_initial
88
+
89
+ def last_name_initial
90
+ object.last_name[0].upcase
91
+ end
92
+ end
93
+ ```
94
+
95
+ ```sql
96
+ SELECT first_name, last_name FROM users
97
+ ```
98
+
99
+ You will be reminded with an exception if you forget to do this.
100
+
101
+ ### Show action
102
+
103
+ You will probably want to reuse the same preloading rules in your show action.
104
+ You just need to call `.for_serializer` on the scope. You can optionally give it
105
+ the serializer class as an argument.
106
+
107
+ ```ruby
108
+ class UsersController < ApplicationController
109
+ def show
110
+ post = Post.for_serializer.find(params[:id])
111
+ # also works:
112
+ # post = Post.for_serializer(PostSerializer).find(params[:id])
113
+
114
+ render json: post
115
+ end
116
+ end
117
+ ```
118
+
119
+ ## Advanced Usage
120
+
121
+ ### Query attributes
122
+
123
+ You can specify a SQL fragment for `SELECT` and use that as an attribute on your
124
+ model. As a simple example you can concatenate 2 strings together in SQL:
125
+
126
+ ```ruby
127
+ class User < ActiveRecord::Base
128
+ datasource_module do
129
+ query :full_name do
130
+ "users.first_name || ' ' || users.last_name"
131
+ end
132
+ end
133
+ end
134
+
135
+ class UserSerializer < ActiveModel::Serializer
136
+ attributes :id, :full_name
137
+ end
138
+ ```
139
+
140
+ ```sql
141
+ SELECT users.id, (users.first_name || ' ' || users.last_name) AS full_name FROM users
142
+ ```
143
+
144
+ ### Loaders
145
+
146
+ You might want to have some more complex preloading logic. In that case you can use a loader.
147
+ The loader will receive ids of the records, and you need to return a hash with your data.
148
+ The key of the hash must be the id of the record for which the data is.
149
+
150
+ A loader will only be executed if a computed attribute depends on it. If an attribute depends
151
+ on multiple loaders, pass an array of loaders like so `computed :attr, loaders: [:loader1, :loader2]`.
152
+
153
+ Be careful that if your hash does not contain a value for the object ID, the loaded value
154
+ will be nil.
155
+
156
+ ```ruby
157
+ class User < ActiveRecord::Base
158
+ datasource_module do
159
+ computed :post_count, loaders: :post_counts
160
+ loader :post_counts, array_to_hash: true do |user_ids|
161
+ results = Post
162
+ .where(user_id: user_ids)
163
+ .group(:user_id)
164
+ .pluck("user_id, COUNT(id)")
165
+ end
166
+ end
167
+ end
168
+
169
+ class UserSerializer < ActiveModel::Serializer
170
+ attributes :id, :post_count
171
+
172
+ def post_count
173
+ # Will automatically give you the value for this user's ID
174
+ object.loaded_values[:post_counts] || 0
175
+ end
176
+ end
177
+ ```
178
+
179
+ ```sql
180
+ SELECT users.id FROM users
181
+ SELECT user_id, COUNT(id) FROM posts WHERE user_id IN (?)
182
+ ```
183
+
184
+ Datasource provides shortcuts to transform your data into a hash. Here are examples:
185
+
186
+ ```ruby
187
+ loader :stuff, array_to_hash: true do |ids|
188
+ [[1, "first"], [2, "second"]]
189
+ # will be transformed into
190
+ # { 1 => "first", 2 => "second" }
191
+ end
192
+
193
+ loader :stuff, group_by: :user_id do |ids|
194
+ Post.where(user_id: ids)
195
+ # will be transformed into
196
+ # { 1 => [#<Post>, #<Post>, ...], 2 => [ ... ], ... }
197
+ end
198
+
199
+ loader :stuff, group_by: :user_id, one: true do |ids|
200
+ Post.where(user_id: ids)
201
+ # will be transformed into
202
+ # { 1 => #<Post>, 2 => #<Post>, ... }
203
+ end
204
+
205
+ loader :stuff, group_by: "user_id", one: true do |ids|
206
+ # it works the same way on an array of hashes
207
+ # but be careful about Symbol/String difference
208
+ [{ "title" => "Something", "user_id" => 10 }]
209
+ # will be transformed into
210
+ # { 10 => { "title" => "Something", "user_id" => 10 } }
211
+ end
212
+ ```
@@ -1,116 +1,186 @@
1
1
  require 'set'
2
+ require 'active_support/concern'
2
3
 
3
- ActiveRecord::Calculations
4
- module ActiveRecord
5
- module Calculations
6
- def pluck_hash(*column_names)
7
- column_names.map! do |column_name|
8
- if column_name.is_a?(Symbol) && attribute_alias?(column_name)
9
- attribute_alias(column_name)
10
- else
11
- column_name.to_s
4
+ module Datasource
5
+ module Adapters
6
+ module ActiveRecord
7
+ module ScopeExtensions
8
+ def use_datasource_serializer(value)
9
+ @datasource_serializer = value
10
+ self
12
11
  end
13
- end
14
12
 
15
- if has_include?(column_names.first)
16
- construct_relation_for_association_calculations.pluck(*column_names)
17
- else
18
- relation = spawn
19
- relation.select_values = column_names.map { |cn|
20
- columns_hash.key?(cn) ? arel_table[cn] : cn
21
- }
22
- result = klass.connection.select_all(relation.arel, nil, bind_values)
23
- columns = result.columns.map do |key|
24
- klass.column_types.fetch(key) {
25
- result.column_types.fetch(key) { result.identity_type }
26
- }
13
+ def use_datasource(value)
14
+ @datasource = value
15
+ self
16
+ end
17
+
18
+ def datasource_select(*args)
19
+ @datasource_select = Array(@datasource_select) + args
20
+ self
27
21
  end
28
22
 
29
- result.rows.map do |values|
30
- {}.tap do |hash|
31
- values.zip(columns, result.columns).each do |v|
32
- single_attr_hash = { v[2] => v[0] }
33
- hash[v[2]] = v[1].type_cast klass.initialize_attributes(single_attr_hash).values.first
23
+ def to_a
24
+ if @datasource
25
+ datasource = @datasource.new(self)
26
+ datasource.select(*Array(@datasource_select))
27
+ if @datasource_serializer
28
+ select = []
29
+ Datasource::Base.consumer_adapter.to_datasource_select(select, @datasource.orm_klass, @datasource_serializer)
30
+
31
+ datasource.select(*select)
34
32
  end
33
+
34
+ datasource.results
35
+ else
36
+ super
35
37
  end
36
38
  end
37
39
  end
38
- end
39
- end
40
- end
41
40
 
42
- module Datasource
43
- module Adapters
44
- module ActiveRecord
45
- ID_KEY = "id"
41
+ module Model
42
+ extend ActiveSupport::Concern
46
43
 
47
- def to_query(scope)
48
- ActiveRecord::Base.uncached do
49
- scope.select(*get_select_values(scope)).to_sql
44
+ included do
45
+ attr_accessor :loaded_values
46
+ end
47
+
48
+ module ClassMethods
49
+ def for_serializer(serializer = nil)
50
+ scope = if all.respond_to?(:use_datasource_serializer)
51
+ all
52
+ else
53
+ all.extending(ScopeExtensions).use_datasource(default_datasource)
54
+ end
55
+ scope.use_datasource_serializer(serializer || Datasource::Base.consumer_adapter.get_serializer_for(Adapters::ActiveRecord.scope_to_class(scope)))
56
+ end
57
+
58
+ def with_datasource(datasource = nil)
59
+ scope = if all.respond_to?(:use_datasource)
60
+ all
61
+ else
62
+ all.extending(ScopeExtensions)
63
+ end
64
+ scope.use_datasource(datasource || default_datasource)
65
+ end
66
+
67
+ def default_datasource
68
+ @default_datasource ||= Class.new(Datasource::From(self))
69
+ end
70
+
71
+ def datasource_module(&block)
72
+ default_datasource.instance_exec(&block)
73
+ end
74
+ end
75
+ end
76
+
77
+ def self.association_reflection(klass, name)
78
+ if reflection = klass.reflections[name]
79
+ {
80
+ klass: reflection.klass,
81
+ macro: reflection.macro,
82
+ foreign_key: reflection.try(:foreign_key)
83
+ }
50
84
  end
51
85
  end
52
86
 
53
- def get_rows(scope)
54
- scope.pluck_hash(*get_select_values(scope))
87
+ def self.get_table_name(klass)
88
+ klass.table_name.to_sym
55
89
  end
56
90
 
57
- def included_datasource_rows(att, datasource_data, rows)
58
- ds_select = datasource_data[:select]
59
- unless ds_select.include?(att[:foreign_key])
60
- ds_select += [att[:foreign_key]]
61
- end
62
- ds_scope = datasource_data[:scope]
63
- column = "#{ds_scope.klass.table_name}.#{att[:foreign_key]}"
64
- ds_scope = ds_scope.where("#{column} IN (?)",
65
- rows.map { |row| row[att[:id_key]] })
66
- grouped_results = att[:klass].new(ds_scope)
67
- .select(ds_select)
68
- .results.group_by do |row|
69
- row[att[:foreign_key]]
70
- end
71
- unless datasource_data[:select].include?(att[:foreign_key])
72
- grouped_results.each_pair do |k, rows|
73
- rows.each do |row|
74
- row.delete(att[:foreign_key])
75
- end
91
+ def self.is_scope?(obj)
92
+ obj.kind_of?(::ActiveRecord::Relation)
93
+ end
94
+
95
+ def self.scope_to_class(scope)
96
+ scope.klass
97
+ end
98
+
99
+ def self.association_klass(reflection)
100
+ if reflection.macro == :belongs_to && reflection.options[:polymorphic]
101
+ fail Datasource::Error, "polymorphic belongs_to not supported, write custom loader"
102
+ else
103
+ reflection.klass
104
+ end
105
+ end
106
+
107
+ def self.preload_association(records, name)
108
+ return if records.empty?
109
+ return if records.first.association(name.to_sym).loaded?
110
+ klass = records.first.class
111
+ if reflection = klass.reflections[name.to_sym]
112
+ assoc_class = association_klass(reflection)
113
+ datasource_class = assoc_class.default_datasource
114
+ # TODO: extract serializer_class from parent serializer association
115
+ serializer_class = Datasource::Base.consumer_adapter.get_serializer_for(assoc_class)
116
+
117
+ # TODO: can we make it use datasource scope (with_serializer)? like Sequel
118
+ scope = assoc_class.all
119
+ datasource = datasource_class.new(scope)
120
+ datasource_select = serializer_class._attributes.dup
121
+ Datasource::Base.reflection_select(Adapters::ActiveRecord.association_reflection(klass, name.to_sym), [], datasource_select)
122
+ datasource.select(*datasource_select)
123
+ select_values = datasource.get_select_values
124
+
125
+ begin
126
+ ::ActiveRecord::Associations::Preloader
127
+ .new.preload(records, name, assoc_class.select(*select_values))
128
+ rescue ArgumentError
129
+ ::ActiveRecord::Associations::Preloader
130
+ .new(records, name, assoc_class.select(*select_values)).run
76
131
  end
132
+
133
+ assoc_records = records.flat_map { |record| record.send(name) }.compact
134
+ serializer_class._associations.each_pair do |assoc_name, options|
135
+ preload_association(assoc_records, assoc_name)
136
+ end
137
+ datasource.results(assoc_records)
138
+ end
139
+ rescue Exception => ex
140
+ if ex.is_a?(SystemStackError) || ex.is_a?(Datasource::RecursionError)
141
+ fail Datasource::RecursionError, "recursive association (involving #{name})"
142
+ else
143
+ raise
77
144
  end
78
- grouped_results
79
145
  end
80
146
 
81
- def get_select_values(scope)
82
- select_values = Set.new
83
- select_values.add("#{scope.klass.table_name}.#{self.class.adapter::ID_KEY}")
84
-
85
- self.class._attributes.each do |att|
86
- if attribute_exposed?(att[:name])
87
- if att[:klass] == nil
88
- select_values.add("#{scope.klass.table_name}.#{att[:name]}")
89
- elsif att[:klass].ancestors.include?(Attributes::ComputedAttribute)
90
- att[:klass]._depends.keys.map(&:to_s).each do |name|
91
- next if name == scope.klass.table_name
92
- ensure_table_join!(scope, name, att)
93
- end
94
- att[:klass]._depends.each_pair do |table, names|
95
- Array(names).each do |name|
96
- select_values.add("#{table}.#{name}")
97
- end
98
- # TODO: handle depends on virtual attribute
99
- end
100
- elsif att[:klass].ancestors.include?(Attributes::QueryAttribute)
101
- select_values.add("(#{att[:klass].new.select_value}) as #{att[:name]}")
102
- att[:klass]._depends.each do |name|
103
- next if name == scope.klass.table_name
104
- ensure_table_join!(scope, name, att)
105
- end
106
- end
147
+ def to_query
148
+ ::ActiveRecord::Base.uncached do
149
+ @scope.select(*get_select_values).to_sql
150
+ end
151
+ end
152
+
153
+ def select_scope
154
+ @scope.select(*get_select_values)
155
+ end
156
+
157
+ def get_rows
158
+ append_select = []
159
+ @expose_associations.each_pair do |assoc_name, assoc_select|
160
+ if reflection = Adapters::ActiveRecord.association_reflection(self.class.orm_klass, assoc_name.to_sym)
161
+ Datasource::Base.reflection_select(reflection, append_select, [])
107
162
  end
108
163
  end
109
- select_values.to_a
164
+ select(*append_select)
165
+
166
+ scope = select_scope
167
+ if scope.respond_to?(:use_datasource)
168
+ scope = scope.spawn.use_datasource(nil)
169
+ end
170
+ scope.includes_values = []
171
+ scope.to_a.tap do |records|
172
+ @expose_associations.each_pair do |assoc_name, assoc_select|
173
+ Adapters::ActiveRecord.preload_association(records, assoc_name)
174
+ end
175
+ end
176
+ end
177
+
178
+ def primary_scope_table(scope)
179
+ scope.klass.table_name
110
180
  end
111
181
 
112
- def ensure_table_join!(scope, name, att)
113
- join_value = scope.joins_values.find do |value|
182
+ def ensure_table_join!(name, att)
183
+ join_value = @scope.joins_values.find do |value|
114
184
  if value.is_a?(Symbol)
115
185
  value.to_s == att[:name]
116
186
  elsif value.is_a?(String)
@@ -119,8 +189,37 @@ module Datasource
119
189
  end
120
190
  end
121
191
  end
122
- raise "Given scope does not join on #{name}, but it is required by #{att[:name]}" unless join_value
192
+ fail Datasource::Error, "given scope does not join on #{name}, but it is required by #{att[:name]}" unless join_value
193
+ end
194
+
195
+ module DatasourceGenerator
196
+ def From(klass)
197
+ if klass.ancestors.include?(::ActiveRecord::Base)
198
+ Class.new(Datasource::Base) do
199
+ attributes *klass.column_names
200
+ associations *klass.reflections.keys
201
+
202
+ define_singleton_method(:orm_klass) do
203
+ klass
204
+ end
205
+
206
+ define_method(:primary_key) do
207
+ klass.primary_key.to_sym
208
+ end
209
+ end
210
+ else
211
+ super if defined?(super)
212
+ end
213
+ end
123
214
  end
124
215
  end
125
216
  end
217
+
218
+ extend Adapters::ActiveRecord::DatasourceGenerator
219
+ end
220
+
221
+ if not(::ActiveRecord::Base.respond_to?(:datasource_module))
222
+ class ::ActiveRecord::Base
223
+ include Datasource::Adapters::ActiveRecord::Model
224
+ end
126
225
  end