remote_record 0.5.0 → 0.8.1

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
  SHA256:
3
- metadata.gz: 4bf471bd03525a3f6dfdfedc1d88e2681264c85e98447607259fec7a94d56ddd
4
- data.tar.gz: 076113352a325ee80c09cca58a00cdd8e703a584392319f008393fdae90ddbc2
3
+ metadata.gz: b0cee90c4d3ebf7acd0d02efebc3285ed4f568e8d42dae1b591a49d6a243bc9c
4
+ data.tar.gz: b2810727cc559a069b0905f55fb89fe5d3e6d63887cf6a5ef7b4bf8b0b738990
5
5
  SHA512:
6
- metadata.gz: 3dc11719e885adf6220ef9b351b1605943ce25220c3e95ee0ee8820175685783777ed7f1bd43f22249f83cb95e8031509e2b85f8be04b65dc33aaab0c4fdca22
7
- data.tar.gz: 478180ddc5b281c7a9873313608ad62368f9c947125fd1095b1c61a88e9cce5147637ca1391c801a0af1c2f53e4ac34418256a38563893f926a4f55ee37d62b4
6
+ metadata.gz: 86e914f8f66772ae586c17dca8d68e1a0a9b238a11461ff9a302bf8b14f8d9512496a0d0eb516c59746ba2f4dd963314452fe35ff5a16aa6d008016114538e79
7
+ data.tar.gz: d6c3207d6bf433b98174e1ba87304d6271b8242cdd27960cfe406b71cd16b07e6881371b8c1318d7c5b6be7f9f7917a63dfdc0064ef7db79e9f482eaf41a5056
data/README.md CHANGED
@@ -124,6 +124,105 @@ caching by way of expiry or ETags, I recommend using `faraday-http-cache` for
124
124
  your clients and setting `memoize` to `false`. Remote Record will eventually
125
125
  gain support for caching.
126
126
 
127
+ ### `remote_all` and `remote_where`
128
+
129
+ If you're able to fetch multiple records at once from the API, implement the
130
+ `self.all` method on your remote record class. This should return an array of
131
+ hashes that can be used to initialize a set of references.
132
+
133
+ This can optionally take a block
134
+ for authorization - note that it won't use the auth you've configured and that
135
+ you'll always have to supply that inline. For example:
136
+
137
+ ```ruby
138
+ module RemoteRecord
139
+ module GitHub
140
+ # :nodoc:
141
+ class User < RemoteRecord::Base
142
+ def get
143
+ client.user(remote_resource_id)
144
+ end
145
+
146
+ def self.all
147
+ Octokit::Client.new(access_token: yield).users
148
+ end
149
+
150
+ private
151
+
152
+ def client
153
+ Octokit::Client.new(access_token: authorization)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ ```
159
+
160
+ Now you can call `remote_all` on remote reference classes that use
161
+ `RemoteRecord::GitHub::User`, like this:
162
+
163
+ ```ruby
164
+ GitHub::UserReference.remote_all { GITHUB_PERSONAL_ACCESS_TOKEN }
165
+ ```
166
+
167
+ `remote_where` works in the same way, but with a parameter:
168
+
169
+ ```ruby
170
+ module RemoteRecord
171
+ module GitHub
172
+ # :nodoc:
173
+ class User < RemoteRecord::Base
174
+ def get
175
+ client.user(remote_resource_id)
176
+ end
177
+
178
+ def self.all
179
+ Octokit::Client.new(access_token: yield).users
180
+ end
181
+
182
+ def self.where(query)
183
+ Octokit::Client.new(access_token: yield).search_users(query)
184
+ end
185
+
186
+ private
187
+
188
+ def client
189
+ Octokit::Client.new(access_token: authorization)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ ```
195
+
196
+ Now you can call `remote_where` on remote reference classes that use
197
+ `RemoteRecord::GitHub::User`, like this:
198
+
199
+ ```ruby
200
+ GitHub::UserReference.remote_where('q=tom+repos:%3E42+followers:%3E1000') { GITHUB_PERSONAL_ACCESS_TOKEN }
201
+ ```
202
+
203
+ It's recommended that you include something in `self.where` to filter incoming
204
+ params. Ideally, you want to expose an interface that's as ActiveRecord-like as
205
+ possible, e.g.:
206
+
207
+ ```ruby
208
+ GitHub::UserReference.remote_where(q: 'tom', repos: '>42', followers: '>1000') { GITHUB_PERSONAL_ACCESS_TOKEN }
209
+ ```
210
+
211
+ It's recommended that you write a `Transformer` to do this. Check out
212
+ `RemoteRecord::Transformers::SnakeCase` for an example.
213
+
214
+ ### `initial_attrs`
215
+
216
+ Behind the scenes, `remote_all` initializes references with a set of
217
+ `initial_attrs`. You can do the same! If you've already fetched the data for an
218
+ object, just pass it to `new` for your reference class under the
219
+ `initial_attrs:` keyword parameter, like this:
220
+
221
+ ```ruby
222
+ todo = { id: 1, title: 'Hello world' }
223
+ TodoReference.new(remote_resource_id: todo[:id], initial_attrs: todo)
224
+ ```
225
+
127
226
  ### Forcing a fresh request
128
227
 
129
228
  You might want to force a fresh request in some instances, even if you're using
@@ -131,4 +230,40 @@ You might want to force a fresh request in some instances, even if you're using
131
230
 
132
231
  ### Skip fetching
133
232
 
134
- You might not want to make a request on initialize sometimes. In this case, pass `fetching: false` to your query or `new` to make sure the resource isn't fetched.
233
+ You might not want to make a request on initialize sometimes. In this case, pass
234
+ `fetching: false` when creating or initializing references to make sure the
235
+ resource isn't fetched.
236
+
237
+ When querying for records using ActiveRecord alone, you might want to do so
238
+ within a `no_fetching` context:
239
+
240
+ ```ruby
241
+ TodoReference.no_fetching { |model| model.where(remote_resource_id: 1) }
242
+ ```
243
+
244
+ Any records initialized within a `no_fetching` context won't be requested. It's
245
+ sort of like a `Faraday` cage, pun entirely intended.
246
+
247
+ If you're using `remote_all` or `remote_where` to fetch using your API, that'll
248
+ automatically use this behind the scenes, then set `attrs` to the response
249
+ value.
250
+
251
+ ### Finding a record without having its canonical ID
252
+
253
+ On many platforms, you might find yourself searching for users by email or
254
+ username. Those aren't canonical IDs - they could change. But searching for them
255
+ by either of those things is a safe bet as a user, nine times out of ten.
256
+
257
+ Similarly, you (or your users) might not always have a remote resource's ID
258
+ upfront. You might, however, have something unique enough to discern it from
259
+ other records, like a user-facing ID. A good example is a pull request reference
260
+ on GitHub - using the repo name, owner's username, and pull request ID, you can
261
+ find a pull request.
262
+
263
+ Of course, that shouldn't be your canonical source, because two of those things
264
+ could change. You could change your username, and you could rename the repo. But
265
+ it's useful to be able to search by those things, right?
266
+
267
+ Implement `find_by` on your remote_record class, and RemoteRecord will use it.
268
+ If you don't, RemoteRecord will fall back to `remote_where`. This takes the same
269
+ params as other class-level RemoteRecord methods, including an auth proc.
@@ -9,10 +9,10 @@ module RemoteRecord
9
9
  Config.defaults.merge(remote_record_class: self)
10
10
  end
11
11
 
12
- def initialize(reference, options)
12
+ def initialize(reference, options = default_config, initial_attrs = {})
13
13
  @reference = reference
14
- @options = options.presence || default_config
15
- @attrs = HashWithIndifferentAccess.new
14
+ @options = options
15
+ @attrs = HashWithIndifferentAccess.new(initial_attrs)
16
16
  end
17
17
 
18
18
  def method_missing(method_name, *_args, &_block)
@@ -29,10 +29,22 @@ module RemoteRecord
29
29
  raise NotImplementedError.new, '#get should return a hash of data that represents the remote record.'
30
30
  end
31
31
 
32
+ def self.all
33
+ raise NotImplementedError.new, '#all should return an array of hashes of data that represent remote records.'
34
+ end
35
+
36
+ def self.where(_params)
37
+ raise NotImplementedError.new, '#where should return an array of hashes of data that represent remote records.'
38
+ end
39
+
32
40
  def fetch
33
41
  @attrs.update(get)
34
42
  end
35
43
 
44
+ def attrs=(new_attrs)
45
+ @attrs.update(new_attrs)
46
+ end
47
+
36
48
  private
37
49
 
38
50
  def transform(data)
@@ -6,10 +6,12 @@ module RemoteRecord
6
6
  # record class (a descendant of RemoteRecord::Base). This is done on
7
7
  # initialize by calling #get on an instance of the remote record class. These
8
8
  # attributes are then accessible on the reference thanks to #method_missing.
9
- module Reference
9
+ module Reference # rubocop:disable Metrics/ModuleLength
10
10
  extend ActiveSupport::Concern
11
11
 
12
- class_methods do
12
+ class_methods do # rubocop:disable Metrics/BlockLength
13
+ attr_accessor :fetching
14
+
13
15
  def remote_record_class
14
16
  ClassLookup.new(self).remote_record_class(
15
17
  remote_record_config.to_h[:remote_record_class]&.to_s
@@ -23,19 +25,104 @@ module RemoteRecord
23
25
  def remote_record_config
24
26
  Config.new
25
27
  end
28
+
29
+ def fetching
30
+ @fetching = true if @fetching.nil?
31
+ @fetching
32
+ end
33
+
34
+ # Disable fetching for all records initialized in the block.
35
+ def no_fetching
36
+ self.fetching = false
37
+ block_return_value = yield(self)
38
+ self.fetching = true
39
+ block_return_value
40
+ end
41
+
42
+ def remote_all(&authz_proc)
43
+ find_or_initialize_all(remote_record_class.all(&authz_proc))
44
+ end
45
+
46
+ def remote_where(params, &authz_proc)
47
+ find_or_initialize_all(remote_record_class.where(params, &authz_proc))
48
+ end
49
+
50
+ def remote_find_by(params, &authz_proc)
51
+ return remote_where(params, &authz_proc).first unless remote_record_class.respond_to?(:find_by)
52
+
53
+ resource = remote_record_class.find_by(params, &authz_proc)
54
+ new(remote_resource_id: resource['id'], initial_attrs: resource)
55
+ end
56
+
57
+ def remote_find_or_initialize_by(params, &authz_proc)
58
+ return remote_where(params, &authz_proc).first unless remote_record_class.respond_to?(:find_by)
59
+
60
+ resource = remote_record_class.find_by(params, &authz_proc)
61
+ find_or_initialize_one(id: resource['id'], initial_attrs: resource)
62
+ end
63
+
64
+ private
65
+
66
+ def find_or_initialize_one(id:, initial_attrs:)
67
+ existing_record = no_fetching { find_by(remote_resource_id: id) }
68
+ return existing_record.tap { |r| r.attrs = initial_attrs } if existing_record.present?
69
+
70
+ new(remote_resource_id: id, initial_attrs: initial_attrs)
71
+ end
72
+
73
+ def find_or_initialize_all(remote_resources)
74
+ no_fetching do
75
+ pair_remote_resources_with_records(remote_resources) do |unsaved_resources, relation|
76
+ new_resources = unsaved_resources.map do |resource|
77
+ new(remote_resource_id: resource['id']).tap { |record| record.attrs = resource }
78
+ end
79
+ relation.to_a + new_resources
80
+ end
81
+ end
82
+ end
83
+
84
+ def pair_remote_resources_with_records(remote_resources)
85
+ # get resource ids
86
+ ids = remote_resource_ids(remote_resources)
87
+ # get what exists in the database
88
+ relation = where(remote_resource_id: ids)
89
+ # for each record, set its attrs
90
+ relation.map do |record|
91
+ record.attrs = remote_resources.find do |r|
92
+ r['id'].to_s == record.remote_resource_id.to_s
93
+ end
94
+ end
95
+ unsaved_resources = resources_without_persisted_references(remote_resources, relation)
96
+ yield(unsaved_resources, relation)
97
+ end
98
+
99
+ def remote_resource_ids(remote_resources)
100
+ remote_resources.map { |remote_resource| remote_resource['id'] }
101
+ end
102
+
103
+ def resources_without_persisted_references(remote_resources, relation)
104
+ remote_resources.reject do |resource|
105
+ relation.pluck(:remote_resource_id).include? resource['id']
106
+ end
107
+ end
26
108
  end
27
109
 
28
110
  # rubocop:disable Metrics/BlockLength
29
111
  included do
30
112
  include ActiveSupport::Rescuable
31
- attr_accessor :fetching
113
+ attribute :fetching, :boolean, default: -> { fetching }
114
+ attr_accessor :initial_attrs
32
115
 
33
116
  after_initialize do |reference|
34
- reference.fetching = true if reference.fetching.nil?
117
+ reference.fetching = false if reference.initial_attrs.present?
35
118
  config = reference.class.remote_record_class.default_config.merge(
36
119
  reference.class.remote_record_config.to_h
37
120
  )
38
121
  reference.instance_variable_set('@remote_record_config', config)
122
+ reference.instance_variable_set('@instance',
123
+ @remote_record_config.remote_record_class.new(
124
+ self, @remote_record_config, reference.initial_attrs.presence || {}
125
+ ))
39
126
  reference.fetch_remote_resource
40
127
  end
41
128
 
@@ -62,7 +149,7 @@ module RemoteRecord
62
149
  self
63
150
  end
64
151
 
65
- private
152
+ delegate :attrs=, to: :@instance
66
153
 
67
154
  def instance
68
155
  @instance ||= @remote_record_config.remote_record_class.new(self, @remote_record_config)
@@ -4,8 +4,11 @@ module RemoteRecord
4
4
  module Transformers
5
5
  # Base transformer class. Inherit from this and implement `#transform`.
6
6
  class Base
7
- def initialize(data)
7
+ def initialize(data, direction = :up)
8
+ raise ArgumentError, 'The direction should be one of :up or :down.' unless %i[up down].include? direction
9
+
8
10
  @data = data
11
+ @direction = direction
9
12
  end
10
13
 
11
14
  def transform
@@ -15,14 +15,19 @@ module RemoteRecord
15
15
  when Array
16
16
  value.map { |v| convert_hash_keys(v) }
17
17
  when Hash
18
- Hash[value.map { |k, v| [underscore_key(k), convert_hash_keys(v)] }]
18
+ Hash[value.map { |k, v| [transform_key(k), convert_hash_keys(v)] }]
19
19
  else
20
20
  value
21
21
  end
22
22
  end
23
23
 
24
- def underscore_key(key)
25
- key.to_s.underscore.to_sym
24
+ def transform_key(key)
25
+ case @direction
26
+ when :up
27
+ key.to_s.underscore.to_sym
28
+ when :down
29
+ key.to_s.camelize(:lower).to_sym
30
+ end
26
31
  end
27
32
  end
28
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RemoteRecord
4
- VERSION = '0.5.0'
4
+ VERSION = '0.8.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: remote_record
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Fish
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-01-12 00:00:00.000000000 Z
12
+ date: 2021-02-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -39,20 +39,6 @@ dependencies:
39
39
  - - ">="
40
40
  - !ruby/object:Gem::Version
41
41
  version: '0'
42
- - !ruby/object:Gem::Dependency
43
- name: database_cleaner
44
- requirement: !ruby/object:Gem::Requirement
45
- requirements:
46
- - - ">="
47
- - !ruby/object:Gem::Version
48
- version: '0'
49
- type: :development
50
- prerelease: false
51
- version_requirements: !ruby/object:Gem::Requirement
52
- requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- version: '0'
56
42
  - !ruby/object:Gem::Dependency
57
43
  name: faraday
58
44
  requirement: !ruby/object:Gem::Requirement