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 +4 -4
- data/README.md +211 -0
- data/lib/datasource/adapters/active_record.rb +188 -89
- data/lib/datasource/adapters/sequel.rb +200 -0
- data/lib/datasource/attributes/computed_attribute.rb +11 -15
- data/lib/datasource/attributes/loader.rb +65 -0
- data/lib/datasource/attributes/query_attribute.rb +8 -3
- data/lib/datasource/base.rb +144 -51
- data/lib/datasource/consumer_adapters/active_model_serializers.rb +66 -0
- data/lib/datasource/serializer.rb +117 -0
- data/lib/datasource.rb +21 -3
- data/lib/generators/datasource/install_generator.rb +8 -0
- data/lib/generators/datasource/templates/initializer.rb +11 -0
- data/test/active_record_helper.rb +13 -0
- data/test/schema.rb +1 -15
- data/test/test_helper.rb +4 -2
- data/test/test_loader.rb +79 -0
- data/test/test_scope.rb +50 -0
- data/test/test_serializer.rb +52 -0
- metadata +44 -8
- data/lib/datasource/serializer/composite.rb +0 -119
- data/test/test_datasource.rb +0 -47
- data/test/test_serializer_composite.rb +0 -48
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 714357b54441eac8f8eb1c209dad82fa8c588743
|
4
|
+
data.tar.gz: 90cb1f1ca000d6683b234c6ccff41425b208be2c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
4
|
-
module
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
43
|
-
|
44
|
-
module ActiveRecord
|
45
|
-
ID_KEY = "id"
|
41
|
+
module Model
|
42
|
+
extend ActiveSupport::Concern
|
46
43
|
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
54
|
-
|
87
|
+
def self.get_table_name(klass)
|
88
|
+
klass.table_name.to_sym
|
55
89
|
end
|
56
90
|
|
57
|
-
def
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
end
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
-
|
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!(
|
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
|
-
|
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
|