syncify 0.1.6 → 0.1.10

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
  SHA1:
3
- metadata.gz: bbbff47f10eb17cab6c833696be95392bb9da92b
4
- data.tar.gz: 82dd0f3812f8ec948b2e3c056fcf4ab68b963e76
3
+ metadata.gz: a23e94b5f7ffe7b8015aeb19a88a96835a4bfe06
4
+ data.tar.gz: 8134d393be46563dc8d601be98a66160451720b1
5
5
  SHA512:
6
- metadata.gz: c520fc80891d255cb6fa2e15fbcc522a0c3d3625aca21829b45a705732f106bbd6442ae48af134b4e4a54dffa1f35e008f9c8203e57c29ba34514aba7e47ee97
7
- data.tar.gz: cd2bd81aad8e1d4b8df61e1a58a9713bc7a6002777852585260d896a9214da508951da06e59ea514574ab9040e8e14cdcfc71675834caf3bde6dadadb49064cf
6
+ metadata.gz: 034efd30cd004dc2aa74d00aa7dbbba5906286435acc5a40d85517174bdf3a440831fe0bf5db3d32c7fb049dfc06834aaeec8eab9e3fbeb467842d35f5ffca86
7
+ data.tar.gz: 600c4a061920e6b95e14ea8bcbe9c59cb7435e2714825c3ed9cba22ac6420419b46f147251ce2c118979fc19dc3c2456f55c5dfd6ef3c700a5e9d378985ac771
@@ -1,64 +1,63 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- syncify (0.1.6)
4
+ syncify (0.1.9)
5
5
  active_interaction (~> 3.0)
6
- activerecord (~> 4.2)
6
+ activerecord (~> 5.0)
7
7
  activerecord-import (~> 0.17)
8
8
 
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- actionpack (4.2.11.1)
13
- actionview (= 4.2.11.1)
14
- activesupport (= 4.2.11.1)
15
- rack (~> 1.6)
16
- rack-test (~> 0.6.2)
17
- rails-dom-testing (~> 1.0, >= 1.0.5)
12
+ actionpack (5.2.3)
13
+ actionview (= 5.2.3)
14
+ activesupport (= 5.2.3)
15
+ rack (~> 2.0)
16
+ rack-test (>= 0.6.3)
17
+ rails-dom-testing (~> 2.0)
18
18
  rails-html-sanitizer (~> 1.0, >= 1.0.2)
19
- actionview (4.2.11.1)
20
- activesupport (= 4.2.11.1)
19
+ actionview (5.2.3)
20
+ activesupport (= 5.2.3)
21
21
  builder (~> 3.1)
22
- erubis (~> 2.7.0)
23
- rails-dom-testing (~> 1.0, >= 1.0.5)
22
+ erubi (~> 1.4)
23
+ rails-dom-testing (~> 2.0)
24
24
  rails-html-sanitizer (~> 1.0, >= 1.0.3)
25
25
  active_interaction (3.7.1)
26
26
  activemodel (>= 4, < 7)
27
- activemodel (4.2.11.1)
28
- activesupport (= 4.2.11.1)
29
- builder (~> 3.1)
30
- activerecord (4.2.11.1)
31
- activemodel (= 4.2.11.1)
32
- activesupport (= 4.2.11.1)
33
- arel (~> 6.0)
27
+ activemodel (5.2.3)
28
+ activesupport (= 5.2.3)
29
+ activerecord (5.2.3)
30
+ activemodel (= 5.2.3)
31
+ activesupport (= 5.2.3)
32
+ arel (>= 9.0)
34
33
  activerecord-import (0.28.2)
35
34
  activerecord (>= 3.2)
36
- activesupport (4.2.11.1)
37
- i18n (~> 0.7)
35
+ activesupport (5.2.3)
36
+ concurrent-ruby (~> 1.0, >= 1.0.2)
37
+ i18n (>= 0.7, < 2)
38
38
  minitest (~> 5.1)
39
- thread_safe (~> 0.3, >= 0.3.4)
40
39
  tzinfo (~> 1.1)
41
- arel (6.0.4)
40
+ arel (9.0.0)
42
41
  builder (3.2.3)
43
42
  byebug (11.0.1)
44
43
  coderay (1.1.2)
45
44
  concurrent-ruby (1.1.5)
46
- crass (1.0.4)
45
+ crass (1.0.5)
47
46
  diff-lcs (1.3)
48
- erubis (2.7.0)
47
+ erubi (1.9.0)
49
48
  factory_bot (4.11.1)
50
49
  activesupport (>= 3.0.0)
51
50
  factory_bot_rails (4.11.1)
52
51
  factory_bot (~> 4.11.1)
53
52
  railties (>= 3.0.0)
54
- i18n (0.9.5)
53
+ i18n (1.7.0)
55
54
  concurrent-ruby (~> 1.0)
56
- loofah (2.2.3)
55
+ loofah (2.3.1)
57
56
  crass (~> 1.0.2)
58
57
  nokogiri (>= 1.5.9)
59
58
  method_source (0.9.2)
60
59
  mini_portile2 (2.4.0)
61
- minitest (5.11.3)
60
+ minitest (5.12.2)
62
61
  nokogiri (1.10.4)
63
62
  mini_portile2 (~> 2.4.0)
64
63
  pry (0.12.2)
@@ -67,36 +66,34 @@ GEM
67
66
  pry-byebug (3.7.0)
68
67
  byebug (~> 11.0)
69
68
  pry (~> 0.10)
70
- rack (1.6.11)
71
- rack-test (0.6.3)
72
- rack (>= 1.0)
73
- rails-deprecated_sanitizer (1.0.3)
74
- activesupport (>= 4.2.0.alpha)
75
- rails-dom-testing (1.0.9)
76
- activesupport (>= 4.2.0, < 5.0)
77
- nokogiri (~> 1.6)
78
- rails-deprecated_sanitizer (>= 1.0.1)
79
- rails-html-sanitizer (1.2.0)
80
- loofah (~> 2.2, >= 2.2.2)
81
- railties (4.2.11.1)
82
- actionpack (= 4.2.11.1)
83
- activesupport (= 4.2.11.1)
69
+ rack (2.0.7)
70
+ rack-test (1.1.0)
71
+ rack (>= 1.0, < 3)
72
+ rails-dom-testing (2.0.3)
73
+ activesupport (>= 4.2.0)
74
+ nokogiri (>= 1.6)
75
+ rails-html-sanitizer (1.3.0)
76
+ loofah (~> 2.3)
77
+ railties (5.2.3)
78
+ actionpack (= 5.2.3)
79
+ activesupport (= 5.2.3)
80
+ method_source
84
81
  rake (>= 0.8.7)
85
- thor (>= 0.18.1, < 2.0)
82
+ thor (>= 0.19.0, < 2.0)
86
83
  rake (10.5.0)
87
- rspec (3.8.0)
88
- rspec-core (~> 3.8.0)
89
- rspec-expectations (~> 3.8.0)
90
- rspec-mocks (~> 3.8.0)
91
- rspec-core (3.8.2)
92
- rspec-support (~> 3.8.0)
93
- rspec-expectations (3.8.4)
84
+ rspec (3.9.0)
85
+ rspec-core (~> 3.9.0)
86
+ rspec-expectations (~> 3.9.0)
87
+ rspec-mocks (~> 3.9.0)
88
+ rspec-core (3.9.0)
89
+ rspec-support (~> 3.9.0)
90
+ rspec-expectations (3.9.0)
94
91
  diff-lcs (>= 1.2.0, < 2.0)
95
- rspec-support (~> 3.8.0)
96
- rspec-mocks (3.8.1)
92
+ rspec-support (~> 3.9.0)
93
+ rspec-mocks (3.9.0)
97
94
  diff-lcs (>= 1.2.0, < 2.0)
98
- rspec-support (~> 3.8.0)
99
- rspec-support (3.8.2)
95
+ rspec-support (~> 3.9.0)
96
+ rspec-support (3.9.0)
100
97
  sqlite3 (1.3.13)
101
98
  thor (0.20.3)
102
99
  thread_safe (0.3.6)
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Syncify
2
2
 
3
- Syncify is a gem used to sync records and associations you specify from one remote ActiveRecord environment to your local environment.
3
+ Syncify is a gem used to sync ActiveRecord records and their associations from one remote environment to your local environment.
4
4
 
5
5
  Consider this hypothetical problem: You have a gigantic production database with complex associations between your models, including polymorphic associations. The database includes sensitive data that shouldn't really be in your development or staging environments. But, there's something wrong in production and you need production data to be able to debug it. It's not practical, efficient, safe, or generally advisable to restore a backup of the production database locally.
6
6
 
@@ -84,7 +84,7 @@ Running the above example will copy two records into your local database:
84
84
  * The `Widget` with id 123 (Lubricated Stainless Steel Helical Insert)
85
85
  * The `Manufacturer` with id 78 (South Seas Trading Company)
86
86
 
87
- It's important to note that Syncify _does not_ recursively follow associations. You'll note that not all of the the manufacturer's widgets were synced, only the one we specified.
87
+ It's important to note that Syncify _does not_ recursively follow associations (though see below for how to discover associations programmatically). You'll note that not all of the the manufacturer's widgets were synced, only the one we specified.
88
88
 
89
89
  The `association` attribute passed into the `run!` method can be any valid value that you might use when joining records with ActiveRecord. The above effectively becomes:
90
90
 
@@ -124,7 +124,9 @@ Syncify::Sync.run!(klass: Manufacturer,
124
124
  remote_database: :production)
125
125
  ```
126
126
 
127
- You can really go wild with the associations; well beyond what you could normally run with an ActiveRecord query! In one app I have a hash defining a ton of associations across dozens of models that is more than 150 lines long. When I run this sync it identifies more than 500 records and syncs them all to local dev in about 30 seconds.
127
+ You can really go wild with the associations; well beyond what you could normally run with an ActiveRecord query!
128
+
129
+ > When Syncify was first released, I had an app with a hash defining a ton of associations across dozens of models that was more than 150 lines long. When I ran this sync it would identify more than 500 records and syncs them all to local dev in about 30 seconds. I've since updated to use association discovery (documented below) and sync _much_ more data. It takes longer, but it's still very fast.
128
130
 
129
131
  ### Polymorphic Associations
130
132
 
@@ -155,24 +157,36 @@ Here's our model:
155
157
 
156
158
  There's a lot going on above, and I'll spare you the example database tables. You can use your imagination! 😉
157
159
 
158
- Let's say we want to sync a particular `LineItem`. With ActiveRecord queries you can't simply `eager_load` across a polymorphic association, much less to any sub-associations (EG: category or distributor). With Syncify you can.
160
+ Let's say we want to sync a particular `LineItem`. With ActiveRecord queries, you can't simply `eager_load` across a polymorphic association, much less to any sub-associations (EG: `:category` or `:distributor`). With Syncify you can.
159
161
 
160
162
  Here's an example. For simplicity's sake it assumes that the database doesn't use foreign keys. (Don't worry, we'll do a more complex example next!):
161
163
 
162
164
  ```ruby
163
- Syncify::Sync.run!(klass: Customer,
164
- id: 999,
165
- association: Syncify::PolymorphicAssociation.new(
166
- :product,
167
- DigitalProduct => {},
168
- PhysicalProduct => {}
169
- ),
165
+ Syncify::Sync.run!(klass: LineItem,
166
+ id: 42,
167
+ association: {
168
+ product: {
169
+ DigitalProduct => {},
170
+ PhysicalProduct => {}
171
+ }
172
+ },
170
173
  remote_database: :production)
171
174
  ```
172
175
 
173
176
  Assuming that line item 42's product is a `DigitalProduct`, this example would have synced the `LineItem` and its `DigitalProduct` and nothing else.
174
177
 
175
- The `Syncify::PolymorphicAssociation` is saying that, for the `LineItem`'s `product` polymorphic association, when the product is a `DigitalProduct`, sync it with the specified associations (in this case none). When the product is a `PhysicalProduct`, sync it with the specified associations (again, none in this case).
178
+ Let's focus in on the association:
179
+
180
+ ```ruby
181
+ {
182
+ product: {
183
+ DigitalProduct => {},
184
+ PhysicalProduct => {}
185
+ }
186
+ }
187
+ ```
188
+
189
+ We know the `LineItem` has a polymorphic association named `:product` (this is documented above). This association is saying that, for the `LineItem`'s `product` polymorphic association, when the product is a `DigitalProduct`, sync it with the specified associations (in this case none). When the product is a `PhysicalProduct`, sync it with the specified associations (again, none in this case).
176
190
 
177
191
  Now let's say that we want to sync a specific `Customer` and all of their invoices and the related products. IE: the whole kit and caboodle. Here's how you can do it:
178
192
 
@@ -181,17 +195,165 @@ Syncify::Sync.run!(klass: Customer,
181
195
  id: 999,
182
196
  association: {
183
197
  invoices: {
184
- line_items: Syncify::PolymorphicAssociation.new(
185
- :product,
186
- DigitalProduct => :category,
187
- PhysicalProduct => :distributor
188
- )
198
+ line_items: {
199
+ product: {
200
+ DigitalProduct => :category,
201
+ PhysicalProduct => :distributor
202
+ }
203
+ }
189
204
  }
190
205
  },
191
206
  remote_database: :production)
192
207
  ```
193
208
 
194
- This will sync a customer, all of their invoices, all of those invoice's line items. It goes on to sync all of the line item's products, whether digital or physical, as well as the digital product's category and the physical product's distributor.
209
+ This will sync a customer, all of their invoices, and all of those invoice's line items. It goes on to sync all of the line item's products, whether digital or physical, as well as the digital product's category and the physical product's distributor.
210
+
211
+ ### Discovering Associations Programmatically
212
+
213
+ The process of specifying associations, as outlined above, might be fairly tedious, especially if you have a hierarchy of dozens of interrelated models. For this reason, Syncify also includes a class that can discover associations, `Syncify::IdentifyAssociations`. Like `Syncify::Sync`, this class has one method, `run!`. You can use it like this:
214
+
215
+ ```ruby
216
+ associations = Syncify::IdentifyAssociations.run!(klass: Customer)
217
+ ```
218
+
219
+ This will inspect the local `Customer` class, discover its associations, and then drill down through those associations to discover nested associations. It proactively cuts out associations that are inverses of other associations, and endeavors to eradicate association loops. So, looking at the customer/invoices/products example above, it will recognize the association from `Customer` to `Invoice`, but not from `Invoice` to `Customer`, since it's the inverse of the first association. It also skips over `has_many through:` associations, since those _must_ be covered by another association.
220
+
221
+ Using the example above, the associations identified would look like this:
222
+
223
+ ```ruby
224
+ {
225
+ invoices: {
226
+ line_items: {
227
+ product: {
228
+ DigitalProduct => :category,
229
+ PhysicalProduct => :distributor
230
+ }
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ > Important Note: Polymorphic associations are discovered by querying the database for associated types. So, in the example above, the `IdentifyAssociations` class sees the `LineItem#products` association and queries the `line_items` table for the set of distinct values in the `product_type` column. It uses that to continue discovery. So, if you're trying to discover associations, but your database is empty, you won't be able to traverse these polymorphic associations.
237
+
238
+ The example above can be see in the specs at spec/lib/syncify/identify_associations_spec.rb.
239
+
240
+ So, you can combine the `Sync` class and the `IdentifyAssociations` class to make your live even easier:
241
+
242
+ ```ruby
243
+ Syncify::Sync.run!(
244
+ klass: Customer,
245
+ id: 999,
246
+ association: Syncify::IdentifyAssociations.run!(klass: Customer),
247
+ remote_database: :production
248
+ )
249
+ ```
250
+
251
+ #### Using `IdentifyAssociations` Remotely
252
+
253
+ Under some circumstances you may want to discover the associations from the remote database. For example, maybe you don't have data in your local database to be able to discover polymorphic associations. For situations like this, `IdentifyAssociations` accepts a `remote_database` argument, just like `Sync`.
254
+
255
+ ```ruby
256
+ Syncify::IdentifyAssociations.run!(klass: Customer, remote_database: :production)
257
+ ```
258
+
259
+ And here it is with `Sync` in all its glory:
260
+
261
+ ```ruby
262
+ Syncify::Sync.run!(
263
+ klass: Customer,
264
+ id: 999,
265
+ association: Syncify::IdentifyAssociations.run!(klass: Customer, remote_database: :production),
266
+ remote_database: :production
267
+ )
268
+ ```
269
+
270
+ #### Association Hints
271
+
272
+ Sometimes, you might not want to automatically discover associations, but not _all_ of them. In these situations you can use hints. A hint is a class that can be used to filter out associations conditionally.
273
+
274
+ The `Hint` class defines the interface for hints. It's a no-op hint that doesn't filter anything. If you need to create your own hints you can extend `Hint`.
275
+
276
+ All hints have two methods:
277
+
278
+ | Method | Description |
279
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
280
+ | `applicable?(candidate_association)` | This method take a Rails association (not a Syncify association, which isn't documented here) and returns a boolean value indicating whether or not the hint is applicable for the specified association. Basically, this is what Syncify uses to determine whether or to check if an association is allowed. |
281
+ | allowed?` | This method returns true or false, indicating if a particular association is allowed to be traversed or not. |
282
+
283
+ You are most likely to use the `BasicHint` class. This class has a constructor that accepts the following arguments:
284
+
285
+ | Argument | Type | Default | Description |
286
+ | ------------- | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
287
+ | `from_class` | Class or array of classes | nil | If provided, the `from_class` argument declares that the hint applies to associations from the specified class or classes. |
288
+ | `association` | Symbol or array of symbols | nil | If provided, the `association` argument declares that the hint applies to associations with the specified name or names. |
289
+ | `to_class` | Class or array of classes | nil | If provided, the `to_class` argument declares that the hint applies to associations to the specified class or classes. |
290
+ | `allowed` | Boolean (required) | | This argument indicates that if the hint is applicable to a particular association that it is or is not allowed, meaning that the `IdentifyAssociations` class will or will not ignore it. |
291
+
292
+ Hints can be specified for use by `IdentifyAssociations` like so:
293
+
294
+ ```ruby
295
+ Syncify::IdentifyAssociations.run!(
296
+ klass: Customer,
297
+ hints: [
298
+ Syncify::Hint::BasicHint.new(....),
299
+ Syncify::Hint::BasicHint.new(....)
300
+ ]
301
+ ```
302
+
303
+ Hints are applied in the order specified and the first one that matches "wins". So, if you have an association that is explicitly disallowed by a hint before another hint allows it, the first hint wins and the association is ignored.
304
+
305
+ With that out of the way, let's assume you have an an association where there are lots of associated records. For example, maybe you're Amazon and you have a `Store` class which has many `Product`s. Obviously, amazon has a bazillion products. We might not want to sync all of these products when syncing a `Store`. You could filter that out with hints in a couple ways.
306
+
307
+ Don't sync by association name:
308
+
309
+ ```ruby
310
+ Syncify::Hint::BasicHint.new(association: :products, allowed: false)
311
+ ```
312
+
313
+ Don't sync by the target class name:
314
+
315
+ ```ruby
316
+ Syncify::Hint::BasicHint.new(to_class: Product, allowed: false)
317
+ ```
318
+
319
+ Perhaps some models always exist locally and remotely. In that case, you could create a hint to never sync them:
320
+
321
+ ```ruby
322
+ Syncify::Hint::BasicHint.new(
323
+ to_class: [
324
+ Config,
325
+ Account,
326
+ Country,
327
+ SiteDomain,
328
+ Offer
329
+ ]
330
+ )
331
+ ```
332
+
333
+ Perhaps you have some classes where none of their associations ever need to be synced. For example, maybe you collect stats on some objects, but the stats aren't needed locally, or there's so many records that it's not practical to sync them all:
334
+
335
+ ```ruby
336
+ Syncify::Hint::BasicHint.new(
337
+ from_class: [
338
+ Account,
339
+ DailyStat,
340
+ LifetimeDailyStat,
341
+ DomainDailyStat,
342
+ PaymentAccount,
343
+ User,
344
+ ],
345
+ allowed: false
346
+ )
347
+ ```
348
+
349
+ Note that in the above example we're disallowing _all_ associations from `Account`. But, let's imagine that `Account` has 50 associations and we _do_ want to sync two of them. Since hints are applied in the order specified, and the first hint that matches is the hint that is applied, you could specifically allow two of the associations from `Account`, but disallow all others like this:
350
+
351
+ ```ruby
352
+ Syncify::Hint::BasicHint.new(from_class: Account, association: [:example1, :example2], allowed: true)
353
+ Syncify::Hint::BasicHint.new(from_class: Account, allowed: false)
354
+ ```
355
+
356
+ If the `BasicHint` class isn't sufficient for your needs, you can always create your own hints by extending `Hint` and implementing the `applicable?` and `allowed?` methods.
195
357
 
196
358
  ### Callbacks
197
359
 
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require "bundler/setup"
4
+ require 'active_interaction'
5
+ require 'pry-byebug'
4
6
  require "syncify"
5
7
 
6
8
  # You can add fixtures and/or initialization code here to make experimenting
@@ -2,8 +2,15 @@ require "syncify/version"
2
2
 
3
3
  $LOAD_PATH.unshift(File.dirname(__FILE__))
4
4
 
5
- Dir[File.expand_path('syncify/*.rb', __dir__)].each { |f| require f }
5
+ require 'syncify/identify_associations'
6
+ require 'syncify/normalize_associations'
7
+ require 'syncify/sync'
8
+ require 'syncify/association/polymorphic_association'
9
+ require 'syncify/association/standard_association'
10
+ require 'syncify/hint/hint'
11
+ require 'syncify/hint/basic_hint'
6
12
 
7
13
  module Syncify
8
- class Error < StandardError; end
14
+ class Error < StandardError;
15
+ end
9
16
  end
@@ -0,0 +1,62 @@
1
+ module Syncify
2
+ module Association
3
+ class PolymorphicAssociation
4
+ attr_accessor :from_class, :to_classes, :name, :destination, :traversed
5
+
6
+ def self.identify_to_classes(from_class, association_name)
7
+ association = from_class.reflect_on_association(association_name)
8
+ @cache ||= {}
9
+ @cache[from_class] ||= {}
10
+ @cache[from_class][association_name] ||= from_class.
11
+ where("#{association.foreign_type} != ''").
12
+ distinct.
13
+ pluck(association.foreign_type).
14
+ uniq.
15
+ compact.
16
+ map(&:constantize)
17
+ end
18
+
19
+ def initialize(from_class:, association:, destination:)
20
+ @from_class = from_class
21
+ @to_classes = Syncify::Association::PolymorphicAssociation.identify_to_classes(from_class, association.name)
22
+ @name = association.name
23
+ @destination = destination
24
+ @traversed = false
25
+ end
26
+
27
+ def polymorphic?
28
+ true
29
+ end
30
+
31
+ def traversed?
32
+ traversed
33
+ end
34
+
35
+ def inverse_of?(association)
36
+ if association.polymorphic?
37
+ association.to_classes.include?(from_class) &&
38
+ to_classes.include?(association.from_class)
39
+ else
40
+ from_class == association.to_class &&
41
+ to_classes.include?(association.from_class)
42
+ end
43
+ end
44
+
45
+ def create_destination(association_name)
46
+ destination[name] ||= {}
47
+ destination[name][association_name] = {}
48
+ end
49
+
50
+ def hash
51
+ "#{self.from_class.to_s}#{self.to_classes.map(&:to_s)}#{self.name}".hash
52
+ end
53
+
54
+ def eql?(other_association)
55
+ return false unless other_association.is_a? PolymorphicAssociation
56
+ self.from_class == other_association.from_class &&
57
+ self.to_classes == other_association.to_classes &&
58
+ self.name == other_association.name
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,45 @@
1
+ module Syncify
2
+ module Association
3
+ class StandardAssociation
4
+ attr_accessor :from_class, :to_class, :name, :destination, :traversed
5
+
6
+ def initialize(from_class:, association:, destination:)
7
+ @from_class = from_class
8
+ @to_class = association.klass
9
+ @name = association.name
10
+ @destination = destination
11
+ @traversed = false
12
+ end
13
+
14
+ def polymorphic?
15
+ false
16
+ end
17
+
18
+ def traversed?
19
+ traversed
20
+ end
21
+
22
+ def inverse_of?(association)
23
+ if association.polymorphic?
24
+ association.to_classes.include?(from_class) && association.from_class == to_class
25
+ else
26
+ association.to_class == from_class && association.from_class == to_class
27
+ end
28
+ end
29
+
30
+ def create_destination(name)
31
+ destination[name] = {}
32
+ end
33
+
34
+ def hash
35
+ "#{self.from_class.to_s}#{self.to_class.to_s}#{self.name}".hash
36
+ end
37
+
38
+ def eql?(other_association)
39
+ self.from_class == other_association.from_class &&
40
+ self.to_class == other_association.to_class &&
41
+ self.name == other_association.name
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ module Syncify
2
+ module Hint
3
+ class BasicHint < Syncify::Hint::Hint
4
+ attr_accessor :from_class, :association, :to_class, :allowed
5
+ alias :allowed? :allowed
6
+
7
+ def initialize(from_class: nil, association: nil, to_class: nil, allowed:)
8
+ @from_class = from_class
9
+ @association = association
10
+ @to_class = to_class
11
+ @allowed = allowed
12
+ end
13
+
14
+ def applicable?(candidate_association)
15
+ evaluate_from(candidate_association) &&
16
+ evaluate_association(candidate_association) &&
17
+ evaluate_to_class(candidate_association)
18
+ end
19
+
20
+ def allowed?
21
+ allowed
22
+ end
23
+
24
+ private
25
+
26
+ def evaluate_from(candidate_association)
27
+ from_class.nil? ||
28
+ Array.wrap(from_class).include?(candidate_association.active_record)
29
+ end
30
+
31
+ def evaluate_association(candidate_association)
32
+ return true if association.nil?
33
+
34
+ if association.is_a? Regexp
35
+ candidate_association.name =~ association ? true : false
36
+ else
37
+ Array.wrap(association).include? candidate_association.name
38
+ end
39
+ end
40
+
41
+ def evaluate_to_class(candidate_association)
42
+ return true if to_class.nil?
43
+
44
+ if candidate_association.polymorphic?
45
+ associated_classes = Syncify::Association::PolymorphicAssociation.identify_to_classes(
46
+ candidate_association.active_record,
47
+ candidate_association.name
48
+ )
49
+
50
+ (Array.wrap(to_class) & associated_classes).any?
51
+ else
52
+ Array.wrap(to_class).include?(candidate_association.klass)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ module Syncify
2
+ module Hint
3
+ class Hint
4
+ def applicable?(candidate_association)
5
+ false
6
+ end
7
+
8
+ def allowed?
9
+ true
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,134 @@
1
+ module Syncify
2
+ class IdentifyAssociations < ActiveInteraction::Base
3
+ object :klass, class: Class
4
+ symbol :remote_database, default: nil
5
+ array :hints, default: []
6
+
7
+ attr_accessor :association_registry, :identified_associations
8
+
9
+ def execute
10
+ @association_registry = Set[]
11
+ @identified_associations = {}
12
+
13
+ remote do
14
+ identify_associations(klass, identified_associations)
15
+
16
+ simplify_associations(traverse_associations)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def simplify_associations(associations)
23
+ simplified_associations = associations.each.reduce([]) do |memo, (association, nested_association)|
24
+ simplified_association = if association.is_a? Class
25
+ { association => simplify_associations(nested_association) }
26
+ elsif nested_association.empty?
27
+ association
28
+ else
29
+ { association => simplify_associations(nested_association) }
30
+ end
31
+
32
+ memo << simplified_association
33
+ memo
34
+ end
35
+
36
+ if simplified_associations.map(&:class).uniq == [Hash]
37
+ return simplified_associations.inject({}) { |memo, association| memo.merge(association) }
38
+ end
39
+ return simplified_associations.first if simplified_associations.size == 1
40
+ return nil if simplified_associations.empty?
41
+
42
+ simplified_associations
43
+ end
44
+
45
+ def identify_associations(from_class, destination)
46
+ applicable_associations(from_class).each do |association|
47
+ puts "Inspecting #{from_class.name}##{association.name}"
48
+ pending_association = if association.polymorphic?
49
+ Syncify::Association::PolymorphicAssociation.new(
50
+ from_class: from_class,
51
+ association: association,
52
+ destination: destination
53
+ )
54
+ else
55
+ Syncify::Association::StandardAssociation.new(
56
+ from_class: from_class,
57
+ association: association,
58
+ destination: destination
59
+ )
60
+ end
61
+
62
+ association_registry << pending_association
63
+ end
64
+ end
65
+
66
+ def traverse_associations
67
+ while (association = next_untraversed_association)
68
+ association.traversed = true
69
+
70
+ next if should_ignore_association?(association)
71
+
72
+ if association.polymorphic?
73
+ association.to_classes.each do |to_class|
74
+ identify_associations(
75
+ to_class,
76
+ association.create_destination(to_class)
77
+ )
78
+ end
79
+ else
80
+ identify_associations(
81
+ association.to_class,
82
+ association.create_destination(association.name)
83
+ )
84
+ end
85
+ end
86
+
87
+ identified_associations
88
+ end
89
+
90
+ def should_ignore_association?(association)
91
+ # ignore if association is the inverse of an association that has already been traversed
92
+ traversed_associations.find do |traversed_association|
93
+ traversed_association.inverse_of?(association)
94
+ end
95
+ end
96
+
97
+ def traversed_associations
98
+ association_registry.select { |association| association.traversed }
99
+ end
100
+
101
+ def next_untraversed_association
102
+ association_registry.find { |association| !association.traversed }
103
+ end
104
+
105
+ def applicable_associations(klass)
106
+ klass.
107
+ reflect_on_all_associations.
108
+ reject(&method(:inapplicable_associations))
109
+ end
110
+
111
+ def inapplicable_associations(association)
112
+ return true if association.class == ActiveRecord::Reflection::ThroughReflection
113
+
114
+ hints.each do |hint|
115
+ return !hint.allowed? if hint.applicable?(association)
116
+ end
117
+
118
+ false
119
+ end
120
+
121
+ # TODO: this is duplicated from Sync. Consider refactoring
122
+ def remote
123
+ run_in_environment(remote_database) { yield }
124
+ end
125
+
126
+ def run_in_environment(environment)
127
+ initial_config = ActiveRecord::Base.connection_config
128
+ ActiveRecord::Base.establish_connection environment
129
+ yield
130
+ ensure
131
+ ActiveRecord::Base.establish_connection initial_config
132
+ end
133
+ end
134
+ end
@@ -19,13 +19,18 @@ module Syncify
19
19
  association.map { |node| normalize_associations(node) }
20
20
  when Hash
21
21
  association.reduce([]) do |memo, (key, value)|
22
- values = normalize_associations(value)
23
-
24
- if values.empty?
25
- memo << Hash[key, {}]
22
+ if polymorphic_values?(value)
23
+ value = value.reduce({}, :merge) if value.is_a? Array
24
+ memo << Hash[key, value]
26
25
  else
27
- values.each do |value|
28
- memo << Hash[key, value]
26
+ values = normalize_associations(value)
27
+
28
+ if values.empty?
29
+ memo << Hash[key, {}]
30
+ else
31
+ values.each do |value|
32
+ memo << Hash[key, value]
33
+ end
29
34
  end
30
35
  end
31
36
 
@@ -36,5 +41,18 @@ module Syncify
36
41
  end
37
42
  ).flatten
38
43
  end
44
+
45
+ private
46
+
47
+ def polymorphic_values?(values)
48
+ if values.is_a? Hash
49
+ values.keys.all? { |key| key.is_a? Class }
50
+ elsif values.is_a? Array
51
+ return false unless values.all? { |value| value.is_a? Hash }
52
+ return polymorphic_values?(values.reduce({}, :merge))
53
+ else
54
+ false
55
+ end
56
+ end
39
57
  end
40
58
  end
@@ -21,12 +21,13 @@ module Syncify
21
21
  @has_and_belongs_to_many_associations = {}
22
22
 
23
23
  remote do
24
+ associations = normalized_associations(association)
24
25
  initial_query.each do |root_record|
25
- identify_associated_records(root_record, normalized_associations(association))
26
+ identify_associated_records(root_record, associations)
26
27
  end
27
28
  end
28
29
 
29
- puts "Identified #{identified_records.size} records to sync."
30
+ puts "\nIdentified #{identified_records.size} records to sync."
30
31
 
31
32
  callback.call(identified_records) if callback.present?
32
33
 
@@ -51,7 +52,11 @@ module Syncify
51
52
  end
52
53
 
53
54
  def print_status
54
- print "\rIdentified #{identified_records.size} records..."
55
+ @identified_records_count ||= identified_records.size
56
+ if @identified_records_count != identified_records.size
57
+ puts "Identified #{identified_records.size} records..."
58
+ @identified_records_count = identified_records.size
59
+ end
55
60
  end
56
61
 
57
62
  def identify_associated_records(root, associations)
@@ -62,7 +67,12 @@ module Syncify
62
67
  polymorphic_associations = associations.select(&method(:includes_polymorphic_association))
63
68
 
64
69
  standard_associations.each do |association|
65
- traverse_associations(root.class.eager_load(association).find(root.id), association)
70
+ begin
71
+ traverse_associations(root.class.eager_load(association).find(root.id), association)
72
+ rescue StandardError => e
73
+ binding.pry
74
+ x = 1231231231
75
+ end
66
76
  end
67
77
 
68
78
  identify_polymorphic_associated_records(root, polymorphic_associations)
@@ -70,20 +80,26 @@ module Syncify
70
80
 
71
81
  def identify_polymorphic_associated_records(root, polymorphic_associations)
72
82
  polymorphic_associations.each do |polymorphic_association|
73
- if polymorphic_association.is_a?(Hash)
74
- polymorphic_association.each do |key, association|
75
- Array.wrap(root.__send__(key)).each do |target|
76
- identify_polymorphic_associated_records(target, Array.wrap(association))
77
- end
78
- end
79
- else
80
- target = root.__send__(polymorphic_association.property)
81
- next if target.nil?
82
- type = polymorphic_association.associations.keys.detect do |association_type|
83
- target.is_a?(association_type)
83
+ property = polymorphic_association.keys.first
84
+ nested_associations = polymorphic_association[property]
85
+
86
+ referenced_objects = Array.wrap(root.__send__(property))
87
+ next if referenced_objects.empty?
88
+
89
+ referenced_objects.each do |referenced_object|
90
+ # TODO: there's got to be a better way to express this
91
+ if polymorphic_association.values.first.keys.all? { |key| key.is_a? Class }
92
+ # TODO: please, Doug. Fix the names!!!
93
+ associated_types = nested_associations.keys
94
+ referenced_type = associated_types.find { |type| referenced_object.is_a?(type) }
95
+
96
+ identify_associated_records(
97
+ referenced_object,
98
+ normalized_associations(nested_associations[referenced_type])
99
+ )
100
+ else
101
+ identify_associated_records(referenced_object, normalized_associations(nested_associations))
84
102
  end
85
- associations = polymorphic_association.associations[type]
86
- identify_associated_records(target, normalized_associations(associations))
87
103
  end
88
104
  end
89
105
  end
@@ -96,7 +112,8 @@ module Syncify
96
112
 
97
113
  records.each do |record|
98
114
  associations.each do |association, nested_associations|
99
- if is_through_association?(record, association)
115
+ puts "Traversing #{record.class.name}##{association}"
116
+ if through_association?(record, association)
100
117
  traverse_associations(
101
118
  record.__send__(
102
119
  record.class.reflect_on_association(association).through_reflection.name
@@ -106,7 +123,7 @@ module Syncify
106
123
  else
107
124
  associated_records = record.__send__(association)
108
125
 
109
- if is_has_and_belongs_to_many_association?(record, association)
126
+ if has_and_belongs_to_many_association?(record, association)
110
127
  cache_has_and_belongs_to_many_association(record, association, associated_records)
111
128
  end
112
129
 
@@ -121,12 +138,12 @@ module Syncify
121
138
  has_and_belongs_to_many_associations[record][association] = Array(associated_records)
122
139
  end
123
140
 
124
- def is_has_and_belongs_to_many_association?(record, association)
141
+ def has_and_belongs_to_many_association?(record, association)
125
142
  record.class.reflect_on_association(association).class ==
126
143
  ActiveRecord::Reflection::HasAndBelongsToManyReflection
127
144
  end
128
145
 
129
- def is_through_association?(record, association)
146
+ def through_association?(record, association)
130
147
  record.class.reflect_on_association(association).class ==
131
148
  ActiveRecord::Reflection::ThroughReflection
132
149
  end
@@ -142,12 +159,17 @@ module Syncify
142
159
 
143
160
  has_and_belongs_to_many_associations.each do |record, associations|
144
161
  associations.each do |association, associated_records|
145
- local_record = record.class.find(record.id)
146
- local_associated_records = associated_records.map do |associated_record|
147
- associated_record.class.find(associated_record.id)
162
+ begin
163
+ local_record = record.class.find(record.id)
164
+ local_associated_records = associated_records.map do |associated_record|
165
+ associated_record.class.find(associated_record.id)
166
+ end
167
+ local_record.__send__(association) << local_associated_records
168
+ local_record.save
169
+ rescue StandardError => e
170
+ binding.pry
171
+ x = 123
148
172
  end
149
- local_record.__send__(association) << local_associated_records
150
- local_record.save
151
173
  end
152
174
  end
153
175
  end
@@ -176,7 +198,11 @@ module Syncify
176
198
  end
177
199
 
178
200
  def includes_polymorphic_association(association)
179
- association.to_s.include?('Syncify::PolymorphicAssociation')
201
+ return false if association.nil?
202
+ return false if association == {}
203
+ return true if association.keys.all? { |key| key.is_a? Class }
204
+
205
+ includes_polymorphic_association(association.values.first)
180
206
  end
181
207
 
182
208
  def normalized_associations(association)
@@ -1,3 +1,3 @@
1
1
  module Syncify
2
- VERSION = "0.1.6"
2
+ VERSION = "0.1.10"
3
3
  end
@@ -34,6 +34,6 @@ Gem::Specification.new do |spec|
34
34
  spec.add_development_dependency "factory_bot_rails", "~> 4.11"
35
35
 
36
36
  spec.add_runtime_dependency "active_interaction", "~> 3.0"
37
- spec.add_runtime_dependency "activerecord", "~> 4.2"
37
+ spec.add_runtime_dependency "activerecord", "~> 5.0"
38
38
  spec.add_runtime_dependency "activerecord-import", "~> 0.17"
39
39
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syncify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Hughes
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-08-23 00:00:00.000000000 Z
11
+ date: 2019-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -120,14 +120,14 @@ dependencies:
120
120
  requirements:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
- version: '4.2'
123
+ version: '5.0'
124
124
  type: :runtime
125
125
  prerelease: false
126
126
  version_requirements: !ruby/object:Gem::Requirement
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: '4.2'
130
+ version: '5.0'
131
131
  - !ruby/object:Gem::Dependency
132
132
  name: activerecord-import
133
133
  requirement: !ruby/object:Gem::Requirement
@@ -161,8 +161,12 @@ files:
161
161
  - bin/console
162
162
  - bin/setup
163
163
  - lib/syncify.rb
164
+ - lib/syncify/association/polymorphic_association.rb
165
+ - lib/syncify/association/standard_association.rb
166
+ - lib/syncify/hint/basic_hint.rb
167
+ - lib/syncify/hint/hint.rb
168
+ - lib/syncify/identify_associations.rb
164
169
  - lib/syncify/normalize_associations.rb
165
- - lib/syncify/polymorphic_association.rb
166
170
  - lib/syncify/sync.rb
167
171
  - lib/syncify/version.rb
168
172
  - syncify.gemspec
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Syncify
4
- class PolymorphicAssociation
5
- attr_accessor :property
6
- attr_accessor :associations
7
-
8
- def initialize(property, associations)
9
- @property = property
10
- @associations = associations
11
- end
12
- end
13
- end