remote_record 0.8.0 → 0.9.3

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: 8e59abdc5071a945f3bff2c49d9601fd0a5963fe8476c7df38cae943fe960e67
4
- data.tar.gz: 469a8ba9bdbf28f3b5ab8a9ac8513dda3720bfb122d6adfb09639429454d0ee6
3
+ metadata.gz: c9a29d724b9f37942e9346bbdcd49fe6ae51e94361c1ea4b73397c4e59b0b6c8
4
+ data.tar.gz: 0c25f5d964e1d989768089eac19896dfeedcc34850ea65c60744e136adabdcc8
5
5
  SHA512:
6
- metadata.gz: 7865d6b9f270fb7ffeeb2a8d7cfc7ce298cbfc94f7d1414848dc913b913f71762b1214ab95a9cf1c5e46dbefa9da371fbf25fdf9b75a22aceede815d998f54f7
7
- data.tar.gz: 3c472901d7748ec97086a8e9516d8d8edcb10a49cc9a963f5615a146d431f26622c3020cb0367a17f13c5e4df75848c753cde2ac3da0dd06b471bd53dd7e215b
6
+ metadata.gz: 6796cdb033366d0f47840891d5f20000d6d465a3ac223ba147707634fe23c6ab5abb99bbf29bc796e0f65410cce1f5de3bcbfef25c6167c0e227e5403790998e
7
+ data.tar.gz: e14ba4c233285f71b3734763f0f2ff0a9ea4562be2d6d008eb8d1811785f901bdb0814920a0e2228a7c31a7cf258cca2787f7469234e2b6d6ba18b768ad347da
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![RemoteRecord: Ready-made remote resource structures.](doc/header.svg)
1
+ ![Remote Record: Ready-made remote resource structures.](doc/header.svg)
2
2
 
3
3
  ---
4
4
 
@@ -9,12 +9,12 @@ Every API speaks a different language. Maybe it's REST, maybe it's SOAP, maybe
9
9
  it's GraphQL. Maybe it's got its own Ruby client, or maybe you need to roll your
10
10
  own. But what if you could just pretend it existed in your database?
11
11
 
12
- RemoteRecord provides a consistent ActiveRecord inspired interface for all of
13
- your application's APIs. Store remote resources by ID, and RemoteRecord will
14
- auto-populate instances of your ActiveRecord model with their attributes from
15
- the API. Whether you're dealing with a user on GitHub, a track on Spotify, a
16
- place on Google Maps, or a resource on your internal infrastructure, you can use
17
- RemoteRecord to wrap fetching it.
12
+ Remote Record provides a consistent Active Record-inspired interface for all of
13
+ your application's APIs. Store remote resources by ID, and Remote Record will
14
+ let you access objects containing their attributes from the API. Whether you're
15
+ dealing with a user on GitHub, a track on Spotify, a place on Google Maps, or a
16
+ resource on your internal infrastructure, you can use Remote Record to wrap
17
+ fetching it.
18
18
 
19
19
  ## Setup
20
20
 
@@ -31,7 +31,7 @@ remote resource. In this example, it's `RemoteRecord::GitHub::User`.
31
31
 
32
32
  ### Creating a remote record class
33
33
 
34
- A standard RemoteRecord class looks like this. It should have a `get` method,
34
+ A standard Remote Record class looks like this. It should have a `get` method,
35
35
  which returns a hash of data you'd like to query on the user.
36
36
 
37
37
  `RemoteRecord::Base` exposes private methods for the `remote_resource_id` and
@@ -46,6 +46,8 @@ module RemoteRecord
46
46
  client.user(remote_resource_id)
47
47
  end
48
48
 
49
+ # Implement the Collection class here for fetching multiple records.
50
+
49
51
  private
50
52
 
51
53
  def client
@@ -56,10 +58,22 @@ module RemoteRecord
56
58
  end
57
59
  ```
58
60
 
61
+ These classes can be used in isolation and don't directly depend on Active
62
+ Record. You can use them outside of the context of Active Record or Rails:
63
+
64
+ ```ruby
65
+ RemoteRecord::GitHub::User.new(1)
66
+ => <RemoteRecord::GitHub::User attrs={}>
67
+ ```
68
+
69
+ If you call `fresh` or try to access an attribute, Remote Record will fetch the
70
+ resource and put its data in this instance.
71
+
59
72
  ### Creating a remote reference
60
73
 
61
- To start using your remote record class, `include RemoteRecord` into your reference. Now, whenever
62
- you initialize an instance of your class, it'll be fetched.
74
+ To start using your remote record class, `include RemoteRecord` into your
75
+ reference. Now, whenever you initialize an instance of your class, it'll be
76
+ fetched.
63
77
 
64
78
  Calling `remote_record` in addition to this lets you set some options:
65
79
 
@@ -69,7 +83,7 @@ Calling `remote_record` in addition to this lets you set some options:
69
83
  | id_field | `:remote_resource_id` | The field on the reference that contains the remote resource ID |
70
84
  | authorization | `''` | An object that can be used by the remote record class to authorize a request. This can be a value, or a proc that returns a value that can be used within the remote record class. |
71
85
  | memoize | true | Whether reference instances should memoize the response that populates them |
72
- | transform | [] | Whether the response should be put through a transformer (under RemoteRecord::Transformers). Currently, only `[:snake_case]` is available. |
86
+ | transform | [] | Whether the response should be put through a transformer (under `RemoteRecord::Transformers`). See `lib/remote_record/transformers` for options. |
73
87
 
74
88
  ```ruby
75
89
  module GitHub
@@ -89,181 +103,142 @@ module GitHub
89
103
  end
90
104
  ```
91
105
 
92
- If your API doesn't require authentication at all, you don't even need to
93
- configure it. So at its best, RemoteRecord can be as lightweight as:
106
+ If the default behavior suits you just fine, you don't even need to
107
+ configure it. So at its best, Remote Record can be as lightweight as:
94
108
 
95
109
  ```ruby
96
110
  class JsonPlaceholderAPIReference < ApplicationRecord
97
111
  include RemoteRecord
98
- # Falls back to the defaults, so it's equivalent to then calling:
99
- # remote_record do |c|
100
- # c.authorization proc { }
101
- # c.id_field :remote_resource_id
102
- # c.klass RemoteRecord::JsonPlaceholderAPI, # Inferred from module and class name
103
- # c.memoize true
104
- # c.transform []
105
- # end
112
+ remote_record
106
113
  end
107
114
  ```
108
115
 
109
116
  ## Usage
110
117
 
111
- Now you've got everything lined up to start using your remote reference.
118
+ Now you've got the basics lined up to start using your remote reference.
112
119
 
113
- Whenever a `GitHub::UserReference` is initialized, e.g. by calling:
120
+ Whenever you call `remote` on a `GitHub::UserReference`:
114
121
 
115
122
  ```ruby
116
- user.github_user_references.first
123
+ user.github_user_references.first.remote
117
124
  ```
118
125
 
119
- ...it'll be populated with the GitHub user's data. You can call methods that
120
- return attributes on the user, like `#login` or `#html_url`.
126
+ ...you'll be able to use the GitHub user's data on an instance of
127
+ `RemoteRecord::GitHub::User`. You can call methods that return attributes on the
128
+ user, like `#login` or `#html_url`.
121
129
 
122
- By default, this'll only make a request on initialize. For services that manage
123
- caching by way of expiry or ETags, I recommend using `faraday-http-cache` for
124
- your clients and setting `memoize` to `false`. Remote Record will eventually
125
- gain support for caching.
130
+ For services that manage caching by way of expiry or ETags, I recommend using
131
+ `faraday-http-cache` for your clients and setting `memoize` to `false`. Remote
132
+ Record may eventually gain native support for caching your records to the
133
+ database.
126
134
 
127
- ### `remote_all` and `remote_where`
135
+ ### `remote` scopes
128
136
 
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.
137
+ Remote Record also provides extensions to Active Record scopes. You can call
138
+ `remote` on a scope to fetch all the remote resources at once. By default, this
139
+ will use a single request per resource, which isn't often optimal.
132
140
 
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:
141
+ Implement the `Collection` class under your remote record class to fetch
142
+ multiple records from the API in fewer requests. `all` should return an array
143
+ of references.
144
+
145
+ Inheriting from `RemoteRecord::Collection` grants you some convenience methods
146
+ you can use to pair the remote resources from the response with your existing
147
+ references. Check out the class file under `lib/remote_record` for more details.
136
148
 
137
149
  ```ruby
138
150
  module RemoteRecord
139
151
  module GitHub
140
152
  # :nodoc:
141
153
  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
+ # ...
155
+ class Collection < RemoteRecord::Collection
156
+ def all
157
+ response = client.all_users
158
+ match_remote_resources_by_id(response)
159
+ end
160
+
161
+ private
162
+
163
+ def client
164
+ Octokit::Client.new
165
+ end
154
166
  end
155
167
  end
156
168
  end
157
169
  end
158
170
  ```
159
171
 
160
- Now you can call `remote_all` on remote reference classes that use
161
- `RemoteRecord::GitHub::User`, like this:
172
+ Now you're ready to fetch all your resources at once:
162
173
 
163
174
  ```ruby
164
- GitHub::UserReference.remote_all { GITHUB_PERSONAL_ACCESS_TOKEN }
175
+ GitHub::UserReference.remote.all
165
176
  ```
166
177
 
167
- `remote_where` works in the same way, but with a parameter:
178
+ `remote.where` works in the same way, but with a parameter:
168
179
 
169
180
  ```ruby
170
181
  module RemoteRecord
171
182
  module GitHub
172
183
  # :nodoc:
173
184
  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)
185
+ # ...
186
+ class Collection < RemoteRecord::Collection
187
+ def all
188
+ response = client.all_users
189
+ match_remote_resources_by_id(response)
190
+ end
191
+
192
+ def where(query)
193
+ response = client.search_users(query)
194
+ match_remote_resources_by_id(response)
195
+ end
196
+
197
+ private
198
+
199
+ def client
200
+ Octokit::Client.new
201
+ end
190
202
  end
191
203
  end
192
204
  end
193
205
  end
194
206
  ```
195
207
 
196
- Now you can call `remote_where` on remote reference classes that use
208
+ Now you can call `remote.where` on remote reference classes that use
197
209
  `RemoteRecord::GitHub::User`, like this:
198
210
 
199
211
  ```ruby
200
- GitHub::UserReference.remote_where('q=tom+repos:%3E42+followers:%3E1000') { GITHUB_PERSONAL_ACCESS_TOKEN }
212
+ GitHub::UserReference.remote.where('q=tom+repos:%3E42+followers:%3E1000')
201
213
  ```
202
214
 
203
- It's recommended that you include something in `self.where` to filter incoming
215
+ *Note that the query we're expecting here comes from the Octokit gem. Your API
216
+ client might have a nicer interface.*
217
+
218
+ It's recommended that you include something in `where` to filter incoming
204
219
  params. Ideally, you want to expose an interface that's as ActiveRecord-like as
205
220
  possible, e.g.:
206
221
 
207
222
  ```ruby
208
- GitHub::UserReference.remote_where(q: 'tom', repos: '>42', followers: '>1000') { GITHUB_PERSONAL_ACCESS_TOKEN }
223
+ GitHub::UserReference.remote_where(q: 'tom', repos: '>42', followers: '>1000')
209
224
  ```
210
225
 
211
- It's recommended that you write a `Transformer` to do this. Check out
212
- `RemoteRecord::Transformers::SnakeCase` for an example.
226
+ You can use or write a `Transformer` to do this. Check out the
227
+ `RemoteRecord::Transformers` module for examples.
213
228
 
214
229
  ### `initial_attrs`
215
230
 
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:
231
+ Behind the scenes, `match_remote_resources` sets the remote instance's `attrs`.
232
+ You can do the same! If you've already fetched the data for an object, set it
233
+ via `attrs`, like this:
220
234
 
221
235
  ```ruby
222
236
  todo = { id: 1, title: 'Hello world' }
223
- TodoReference.new(remote_resource_id: todo[:id], initial_attrs: todo)
237
+ todo_reference = TodoReference.new(remote_resource_id: todo[:id])
238
+ todo_reference.remote.attrs = todo
224
239
  ```
225
240
 
226
241
  ### Forcing a fresh request
227
242
 
228
- You might want to force a fresh request in some instances, even if you're using
229
- `memoize`. To do this, call `fresh` on a reference, and it'll be repopulated.
230
-
231
- ### Skip fetching
232
-
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.
243
+ You might want to force a fresh request in some instances. To do this, call
244
+ `fresh` on a reference, and it'll be repopulated.
data/lib/remote_record.rb CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  require 'active_support/concern'
4
4
  require 'active_support/rescuable'
5
+ require 'active_record/type'
6
+ require 'remote_record/type'
5
7
  require 'remote_record/base'
6
8
  require 'remote_record/class_lookup'
9
+ require 'remote_record/collection'
7
10
  require 'remote_record/config'
8
11
  require 'remote_record/dsl'
9
12
  require 'remote_record/reference'
@@ -5,17 +5,27 @@ module RemoteRecord
5
5
  class Base
6
6
  include ActiveSupport::Rescuable
7
7
 
8
- def self.default_config
9
- Config.defaults.merge(remote_record_class: self)
8
+ # When you inherit from `Base`, it'll set up an Active Record Type for you
9
+ # available on its Type constant. It'll also have a Collection.
10
+ def self.inherited(subclass)
11
+ subclass.const_set :Type, RemoteRecord::Type.for(subclass)
12
+ subclass.const_set :Collection, Class.new(RemoteRecord::Collection) unless subclass.const_defined? :Collection
13
+ super
10
14
  end
15
+ attr_reader :remote_resource_id
16
+ attr_accessor :remote_record_config
11
17
 
12
- def initialize(reference, options = default_config, initial_attrs = {})
13
- @reference = reference
14
- @options = options
18
+ def initialize(remote_resource_id,
19
+ remote_record_config = Config.defaults,
20
+ initial_attrs = {})
21
+ @remote_resource_id = remote_resource_id
22
+ @remote_record_config = remote_record_config
15
23
  @attrs = HashWithIndifferentAccess.new(initial_attrs)
24
+ @fetched = initial_attrs.present?
16
25
  end
17
26
 
18
27
  def method_missing(method_name, *_args, &_block)
28
+ fetch unless @remote_record_config.memoize && @fetched
19
29
  transform(@attrs).fetch(method_name)
20
30
  rescue KeyError
21
31
  super
@@ -29,25 +39,26 @@ module RemoteRecord
29
39
  raise NotImplementedError.new, '#get should return a hash of data that represents the remote record.'
30
40
  end
31
41
 
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
-
40
42
  def fetch
41
43
  @attrs.update(get)
44
+ @fetched = true
42
45
  end
43
46
 
44
47
  def attrs=(new_attrs)
45
48
  @attrs.update(new_attrs)
49
+ @fetched = true
50
+ end
51
+
52
+ def fresh
53
+ fetch
54
+ self
46
55
  end
47
56
 
48
57
  private
49
58
 
50
59
  def transform(data)
60
+ return data unless transformers.any?
61
+
51
62
  transformers.reduce(data) do |transformed_data, transformer|
52
63
  transformer.new(transformed_data).transform
53
64
  end
@@ -55,18 +66,14 @@ module RemoteRecord
55
66
 
56
67
  # Robots in disguise.
57
68
  def transformers
58
- @options.transform.map do |transformer_name|
69
+ @remote_record_config.transform.map do |transformer_name|
59
70
  "RemoteRecord::Transformers::#{transformer_name.to_s.camelize}".constantize
60
71
  end
61
72
  end
62
73
 
63
74
  def authorization
64
- authz = @options.authorization
65
- authz.respond_to?(:call) ? authz.call(@reference, @options) : authz
66
- end
67
-
68
- def remote_resource_id
69
- @reference.send(@options.id_field)
75
+ authz = @remote_record_config.authorization
76
+ authz.respond_to?(:call) ? authz.call(@remote_record_config.authorization_source) : authz
70
77
  end
71
78
  end
72
79
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRecord
4
+ # Wraps operations on collections of remote references. By calling #remote on
5
+ # on an ActiveRecord relation, you'll get a RemoteRecord::Collection you can
6
+ # use to more easily fetch multiple records at once.
7
+ #
8
+ # The default implementation is naive and sends a request per object.
9
+ class Collection
10
+ delegate :length, to: :@relation
11
+
12
+ def initialize(active_record_relation, config = nil, id: :remote_resource_id)
13
+ @relation = active_record_relation
14
+ @config = config
15
+ @id_field = id
16
+ end
17
+
18
+ def all
19
+ fetch_all_scoped_records(@relation)
20
+ end
21
+
22
+ def where
23
+ raise NotImplementedError.new,
24
+ "Implement #where on #{self.class.name} to filter records using the API."
25
+ end
26
+
27
+ private
28
+
29
+ # Override this to define more succinct ways to request all records at once.
30
+ # If your API has a search endpoint, you may want to use that. Otherwise,
31
+ # list all objects and leave it to Remote Record to pick out the ones you
32
+ # have in your database.
33
+ def fetch_all_scoped_records(relation)
34
+ relation.map do |record|
35
+ record.remote.remote_record_config.merge!(@config)
36
+ record.tap { |r| r.remote.fresh }
37
+ end
38
+ end
39
+
40
+ def match_remote_resources(response)
41
+ @relation.map do |record|
42
+ prefetched_record = response.find do |resource|
43
+ yield(resource).to_s == record.public_send(@id_field).remote_resource_id
44
+ end
45
+ record.remote.attrs = prefetched_record if prefetched_record.present?
46
+ record
47
+ end
48
+ end
49
+
50
+ def match_remote_resources_by_id(response)
51
+ match_remote_resources(response) { |resource| resource['id'] }
52
+ end
53
+ end
54
+ end
@@ -7,7 +7,7 @@ module RemoteRecord
7
7
  # defaults of the remote record class and the overrides set when
8
8
  # `remote_record` is called.
9
9
  class Config
10
- OPTIONS = %i[remote_record_class authorization memoize id_field transform].freeze
10
+ OPTIONS = %i[authorization authorization_source memoize id_field transform].freeze
11
11
 
12
12
  def initialize(**options)
13
13
  @options = options
@@ -16,6 +16,7 @@ module RemoteRecord
16
16
  def self.defaults
17
17
  new(
18
18
  authorization: '',
19
+ authorization_source: nil,
19
20
  memoize: true,
20
21
  id_field: :remote_resource_id,
21
22
  transform: []
@@ -41,9 +42,19 @@ module RemoteRecord
41
42
  @options
42
43
  end
43
44
 
44
- def merge(**overrides)
45
+ def merge(config = nil, **overrides)
46
+ @options.yield_self { |options| options.merge(**(config || {}).to_h) }
47
+ .yield_self { |options| options.merge(**overrides) }
48
+ end
49
+
50
+ def merge!(config = nil, **overrides)
51
+ @options.merge!(**config.to_h) if config.present?
45
52
  @options.merge!(**overrides)
46
53
  self
47
54
  end
55
+
56
+ def ==(other)
57
+ other.to_h == @options
58
+ end
48
59
  end
49
60
  end
@@ -8,12 +8,16 @@ module RemoteRecord
8
8
  module DSL
9
9
  extend ActiveSupport::Concern
10
10
  class_methods do
11
- def remote_record(remote_record_class: nil)
12
- klass = RemoteRecord::ClassLookup.new(self).remote_record_class(remote_record_class)
13
- config = RemoteRecord::Config.new(remote_record_class: klass)
14
- config = yield(config) if block_given?
15
- DSLPrivate.validate_config(config)
16
- define_singleton_method(:remote_record_config) { config }
11
+ def remote_record(remote_record_class: nil, field: :remote_resource_id)
12
+ klass = DSLPrivate.lookup_and_validate_class(self, remote_record_class)
13
+ base_config = RemoteRecord::Config.defaults
14
+ base_config = yield(base_config) if block_given?
15
+ # Register the field as an Active Record attribute of the remote record
16
+ # class's type
17
+ attribute field, klass::Type[base_config].new
18
+
19
+ DSLPrivate.define_remote_scope(self, klass, field)
20
+ DSLPrivate.define_remote_accessor(self, field)
17
21
  end
18
22
  end
19
23
  end
@@ -21,15 +25,40 @@ module RemoteRecord
21
25
  # Methods private to the DSL module.
22
26
  module DSLPrivate
23
27
  class << self
24
- def responds_to_get?(klass)
25
- klass.instance_methods(false).include? :get
28
+ def lookup_and_validate_class(klass, override)
29
+ RemoteRecord::ClassLookup.new(klass).remote_record_class(override).tap do |found_klass|
30
+ validate_responds_to_get(found_klass)
31
+ end
26
32
  end
27
33
 
28
- def validate_config(config)
29
- klass = RemoteRecord::ClassLookup.new(self.class.to_s)
30
- .remote_record_class(config.to_h[:remote_record_class].to_s)
34
+ # Define the #remote scope, which returns a Collection for the given
35
+ # Remote Record class
36
+ def define_remote_scope(base, klass, field_name)
37
+ return if base.respond_to?(:remote)
38
+
39
+ base.define_singleton_method(:remote) do |id_field = field_name, config: nil|
40
+ klass::Collection.new(all, config, id: id_field)
41
+ end
42
+ end
43
+
44
+ # Define the #remote accessor for instances - this uses the Active
45
+ # Record type, but adds a reference to the parent object into the config
46
+ # to be used in authorization.
47
+ def define_remote_accessor(base, field_name)
48
+ return if base.instance_methods(false).include?(:remote)
49
+
50
+ base.define_method(:remote) do |id_field = field_name|
51
+ self[id_field].tap { |record| record.remote_record_config.merge!(authorization_source: self) }
52
+ end
53
+ end
54
+
55
+ def validate_responds_to_get(klass)
31
56
  raise NotImplementedError.new, 'The remote record does not implement #get.' unless responds_to_get?(klass)
32
57
  end
58
+
59
+ def responds_to_get?(klass)
60
+ klass.instance_methods(false).include? :get
61
+ end
33
62
  end
34
63
  end
35
64
  end
@@ -9,138 +9,8 @@ module RemoteRecord
9
9
  module Reference
10
10
  extend ActiveSupport::Concern
11
11
 
12
- class_methods do # rubocop:disable Metrics/BlockLength
13
- attr_accessor :fetching
14
-
15
- def remote_record_class
16
- ClassLookup.new(self).remote_record_class(
17
- remote_record_config.to_h[:remote_record_class]&.to_s
18
- )
19
- end
20
-
21
- # Default to an empty config, which falls back to the remote record
22
- # class's default config and leaves the remote record class to be inferred
23
- # from the reference class name
24
- # This method is overridden using RemoteRecord::DSL#remote_record.
25
- def remote_record_config
26
- Config.new
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
- private
58
-
59
- def find_or_initialize_all(remote_resources)
60
- no_fetching do
61
- pair_remote_resources_with_records(remote_resources) do |unsaved_resources, relation|
62
- new_resources = unsaved_resources.map do |resource|
63
- new(remote_resource_id: resource['id']).tap { |record| record.attrs = resource }
64
- end
65
- relation.to_a + new_resources
66
- end
67
- end
68
- end
69
-
70
- def pair_remote_resources_with_records(remote_resources)
71
- # get resource ids
72
- ids = remote_resource_ids(remote_resources)
73
- # get what exists in the database
74
- relation = where(remote_resource_id: ids)
75
- # for each record, set its attrs
76
- relation.map do |record|
77
- record.attrs = remote_resources.find do |r|
78
- r['id'].to_s == record.remote_resource_id.to_s
79
- end
80
- end
81
- unsaved_resources = resources_without_persisted_references(remote_resources, relation)
82
- yield(unsaved_resources, relation)
83
- end
84
-
85
- def remote_resource_ids(remote_resources)
86
- remote_resources.map { |remote_resource| remote_resource['id'] }
87
- end
88
-
89
- def resources_without_persisted_references(remote_resources, relation)
90
- remote_resources.reject do |resource|
91
- relation.pluck(:remote_resource_id).include? resource['id']
92
- end
93
- end
94
- end
95
-
96
- # rubocop:disable Metrics/BlockLength
97
12
  included do
98
13
  include ActiveSupport::Rescuable
99
- attribute :fetching, :boolean, default: -> { fetching }
100
- attr_accessor :initial_attrs
101
-
102
- after_initialize do |reference|
103
- reference.fetching = false if reference.initial_attrs.present?
104
- config = reference.class.remote_record_class.default_config.merge(
105
- reference.class.remote_record_config.to_h
106
- )
107
- reference.instance_variable_set('@remote_record_config', config)
108
- reference.instance_variable_set('@instance',
109
- @remote_record_config.remote_record_class.new(
110
- self, @remote_record_config, reference.initial_attrs.presence || {}
111
- ))
112
- reference.fetch_remote_resource
113
- end
114
-
115
- # This doesn't call `super` because it delegates to @instance in all
116
- # cases.
117
- def method_missing(method_name, *_args, &_block)
118
- fetch_remote_resource unless @remote_record_config.memoize
119
-
120
- instance.public_send(method_name)
121
- end
122
-
123
- def respond_to_missing?(method_name, _include_private = false)
124
- instance.respond_to?(method_name, false)
125
- end
126
-
127
- def fetch_remote_resource
128
- instance.fetch if fetching
129
- rescue Exception => e # rubocop:disable Lint/RescueException
130
- rescue_with_handler(e) || raise
131
- end
132
-
133
- def fresh
134
- instance.fetch
135
- self
136
- end
137
-
138
- delegate :attrs=, to: :@instance
139
-
140
- def instance
141
- @instance ||= @remote_record_config.remote_record_class.new(self, @remote_record_config)
142
- end
143
14
  end
144
- # rubocop:enable Metrics/BlockLength
145
15
  end
146
16
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './config'
4
+
5
+ module RemoteRecord
6
+ # RemoteRecord uses the Active Record Types system to serialize to and from a
7
+ # remote resource.
8
+ class Type < ActiveRecord::Type::Value
9
+ class_attribute :config, default: RemoteRecord::Config.defaults, instance_writer: false, instance_predicate: false
10
+ class_attribute :parent, instance_writer: false, instance_predicate: false
11
+
12
+ def type
13
+ :string
14
+ end
15
+
16
+ def cast(_remote_resource_id)
17
+ raise 'cast not defined'
18
+ end
19
+
20
+ def deserialize(value)
21
+ cast(value)
22
+ end
23
+
24
+ def serialize(representation)
25
+ return representation.remote_resource_id if representation.respond_to? :remote_resource_id
26
+
27
+ representation.to_s
28
+ end
29
+
30
+ def self.for(remote_record_class)
31
+ Class.new(self) do |type|
32
+ type.parent = remote_record_class
33
+ def self.[](config_override)
34
+ Class.new(self).tap { |configured_type| configured_type.config = config_override }
35
+ end
36
+
37
+ def cast(remote_resource_id)
38
+ return remote_resource_id if remote_resource_id.is_a?(parent)
39
+
40
+ parent.new(remote_resource_id, config)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RemoteRecord
4
- VERSION = '0.8.0'
4
+ VERSION = '0.9.3'
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.8.0
4
+ version: 0.9.3
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-02-24 00:00:00.000000000 Z
12
+ date: 2021-04-23 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -178,12 +178,14 @@ files:
178
178
  - lib/remote_record.rb
179
179
  - lib/remote_record/base.rb
180
180
  - lib/remote_record/class_lookup.rb
181
+ - lib/remote_record/collection.rb
181
182
  - lib/remote_record/config.rb
182
183
  - lib/remote_record/dsl.rb
183
184
  - lib/remote_record/reference.rb
184
185
  - lib/remote_record/transformers.rb
185
186
  - lib/remote_record/transformers/base.rb
186
187
  - lib/remote_record/transformers/snake_case.rb
188
+ - lib/remote_record/type.rb
187
189
  - lib/remote_record/version.rb
188
190
  homepage: https://github.com/raisedevs/remote_record
189
191
  licenses:
@@ -206,7 +208,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
206
208
  - !ruby/object:Gem::Version
207
209
  version: '0'
208
210
  requirements: []
209
- rubygems_version: 3.1.4
211
+ rubygems_version: 3.1.6
210
212
  signing_key:
211
213
  specification_version: 4
212
214
  summary: Ready-made remote resource structures.