datasource 0.0.1 → 0.0.2

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.
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