remote_record 0.5.0 → 0.8.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.
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