remote_record 0.7.1 → 0.9.2

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: 95e6b11dd488bf26b81544c37471498737a26598429ba8290e4e9c8e0f6d79a0
4
- data.tar.gz: cb125226d35be488de6d72651bb1249539d4d2dec487f75e242fc949593b008c
3
+ metadata.gz: ffa98f2c80cd8bb5cf1c297a30e878a09a056daa139d8109a7179d9076c56f21
4
+ data.tar.gz: df9f87372e8ad4e348db5980acfd7912ed9cbd91fd4c06f18718627b58357c31
5
5
  SHA512:
6
- metadata.gz: '08872bedccc7008cbf05129c2e9e791ee73c88bdc222dbaab1ff973bf32b0e24a284690072394c4b6e9a5126f7c0c851e6a2ffbf7ef855909397fbc77d870cbd'
7
- data.tar.gz: d86cd65b6db39f4191d39b0be6e954b56b118daa02534eb0b8c20e10fcf988d9e07ada0fef2fc418e787b1a112dfc300b31df2f7afde199df88bb3fab68d04ca
6
+ metadata.gz: 9f522c6b252935a1340de6ea9ebdc6a8825610199a71ecbdfb6cbc4de9202167d34326b239bed5b80f14cca2353572a7af96d7f61a703d4dd746056ed6111e99
7
+ data.tar.gz: b5875964305b1cad3a96765f5e0e19714032d47c093701c63ededd93e9aaa7389c907fcfc4d7949588fa6f8a6a3103d7cfb5074714de1ddd18cb29d4b59a8d6a
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,161 +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`.
129
+
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.
121
134
 
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.
135
+ ### `remote` scopes
126
136
 
127
- ### `remote_all` and `remote_where`
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.
128
140
 
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.
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.
132
144
 
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:
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.
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,25 @@ 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)
46
49
  end
47
50
 
51
+ def fresh
52
+ fetch
53
+ self
54
+ end
55
+
48
56
  private
49
57
 
50
58
  def transform(data)
59
+ return data unless transformers.any?
60
+
51
61
  transformers.reduce(data) do |transformed_data, transformer|
52
62
  transformer.new(transformed_data).transform
53
63
  end
@@ -55,18 +65,14 @@ module RemoteRecord
55
65
 
56
66
  # Robots in disguise.
57
67
  def transformers
58
- @options.transform.map do |transformer_name|
68
+ @remote_record_config.transform.map do |transformer_name|
59
69
  "RemoteRecord::Transformers::#{transformer_name.to_s.camelize}".constantize
60
70
  end
61
71
  end
62
72
 
63
73
  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)
74
+ authz = @remote_record_config.authorization
75
+ authz.respond_to?(:call) ? authz.call(@remote_record_config.authorization_source) : authz
70
76
  end
71
77
  end
72
78
  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,131 +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
- private
51
-
52
- def find_or_initialize_all(remote_resources)
53
- no_fetching do
54
- pair_remote_resources_with_records(remote_resources) do |unsaved_resources, relation|
55
- new_resources = unsaved_resources.map do |resource|
56
- new(remote_resource_id: resource['id']).tap { |record| record.attrs = resource }
57
- end
58
- relation.to_a + new_resources
59
- end
60
- end
61
- end
62
-
63
- def pair_remote_resources_with_records(remote_resources)
64
- # get resource ids
65
- ids = remote_resource_ids(remote_resources)
66
- # get what exists in the database
67
- relation = where(remote_resource_id: ids)
68
- # for each record, set its attrs
69
- relation.map do |record|
70
- record.attrs = remote_resources.find do |r|
71
- r['id'].to_s == record.remote_resource_id.to_s
72
- end
73
- end
74
- unsaved_resources = resources_without_persisted_references(remote_resources, relation)
75
- yield(unsaved_resources, relation)
76
- end
77
-
78
- def remote_resource_ids(remote_resources)
79
- remote_resources.map { |remote_resource| remote_resource['id'] }
80
- end
81
-
82
- def resources_without_persisted_references(remote_resources, relation)
83
- remote_resources.reject do |resource|
84
- relation.pluck(:remote_resource_id).include? resource['id']
85
- end
86
- end
87
- end
88
-
89
- # rubocop:disable Metrics/BlockLength
90
12
  included do
91
13
  include ActiveSupport::Rescuable
92
- attribute :fetching, :boolean, default: -> { fetching }
93
- attr_accessor :initial_attrs
94
-
95
- after_initialize do |reference|
96
- reference.fetching = false if reference.initial_attrs.present?
97
- config = reference.class.remote_record_class.default_config.merge(
98
- reference.class.remote_record_config.to_h
99
- )
100
- reference.instance_variable_set('@remote_record_config', config)
101
- reference.instance_variable_set('@instance',
102
- @remote_record_config.remote_record_class.new(
103
- self, @remote_record_config, reference.initial_attrs.presence || {}
104
- ))
105
- reference.fetch_remote_resource
106
- end
107
-
108
- # This doesn't call `super` because it delegates to @instance in all
109
- # cases.
110
- def method_missing(method_name, *_args, &_block)
111
- fetch_remote_resource unless @remote_record_config.memoize
112
-
113
- instance.public_send(method_name)
114
- end
115
-
116
- def respond_to_missing?(method_name, _include_private = false)
117
- instance.respond_to?(method_name, false)
118
- end
119
-
120
- def fetch_remote_resource
121
- instance.fetch if fetching
122
- rescue Exception => e # rubocop:disable Lint/RescueException
123
- rescue_with_handler(e) || raise
124
- end
125
-
126
- def fresh
127
- instance.fetch
128
- self
129
- end
130
-
131
- delegate :attrs=, to: :@instance
132
-
133
- def instance
134
- @instance ||= @remote_record_config.remote_record_class.new(self, @remote_record_config)
135
- end
136
14
  end
137
- # rubocop:enable Metrics/BlockLength
138
15
  end
139
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.7.1'
4
+ VERSION = '0.9.2'
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.7.1
4
+ version: 0.9.2
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-11 00:00:00.000000000 Z
12
+ date: 2021-04-21 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.