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 +4 -4
- data/Gemfile.lock +51 -54
- data/README.md +180 -18
- data/bin/console +2 -0
- data/lib/syncify.rb +9 -2
- data/lib/syncify/association/polymorphic_association.rb +62 -0
- data/lib/syncify/association/standard_association.rb +45 -0
- data/lib/syncify/hint/basic_hint.rb +57 -0
- data/lib/syncify/hint/hint.rb +13 -0
- data/lib/syncify/identify_associations.rb +134 -0
- data/lib/syncify/normalize_associations.rb +24 -6
- data/lib/syncify/sync.rb +53 -27
- data/lib/syncify/version.rb +1 -1
- data/syncify.gemspec +1 -1
- metadata +9 -5
- data/lib/syncify/polymorphic_association.rb +0 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a23e94b5f7ffe7b8015aeb19a88a96835a4bfe06
|
4
|
+
data.tar.gz: 8134d393be46563dc8d601be98a66160451720b1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 034efd30cd004dc2aa74d00aa7dbbba5906286435acc5a40d85517174bdf3a440831fe0bf5db3d32c7fb049dfc06834aaeec8eab9e3fbeb467842d35f5ffca86
|
7
|
+
data.tar.gz: 600c4a061920e6b95e14ea8bcbe9c59cb7435e2714825c3ed9cba22ac6420419b46f147251ce2c118979fc19dc3c2456f55c5dfd6ef3c700a5e9d378985ac771
|
data/Gemfile.lock
CHANGED
@@ -1,64 +1,63 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
syncify (0.1.
|
4
|
+
syncify (0.1.9)
|
5
5
|
active_interaction (~> 3.0)
|
6
|
-
activerecord (~>
|
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 (
|
13
|
-
actionview (=
|
14
|
-
activesupport (=
|
15
|
-
rack (~>
|
16
|
-
rack-test (
|
17
|
-
rails-dom-testing (~>
|
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 (
|
20
|
-
activesupport (=
|
19
|
+
actionview (5.2.3)
|
20
|
+
activesupport (= 5.2.3)
|
21
21
|
builder (~> 3.1)
|
22
|
-
|
23
|
-
rails-dom-testing (~>
|
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 (
|
28
|
-
activesupport (=
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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 (
|
37
|
-
|
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 (
|
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.
|
45
|
+
crass (1.0.5)
|
47
46
|
diff-lcs (1.3)
|
48
|
-
|
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 (
|
53
|
+
i18n (1.7.0)
|
55
54
|
concurrent-ruby (~> 1.0)
|
56
|
-
loofah (2.
|
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.
|
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 (
|
71
|
-
rack-test (
|
72
|
-
rack (>= 1.0)
|
73
|
-
rails-
|
74
|
-
activesupport (>= 4.2.0
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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.
|
82
|
+
thor (>= 0.19.0, < 2.0)
|
86
83
|
rake (10.5.0)
|
87
|
-
rspec (3.
|
88
|
-
rspec-core (~> 3.
|
89
|
-
rspec-expectations (~> 3.
|
90
|
-
rspec-mocks (~> 3.
|
91
|
-
rspec-core (3.
|
92
|
-
rspec-support (~> 3.
|
93
|
-
rspec-expectations (3.
|
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.
|
96
|
-
rspec-mocks (3.
|
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.
|
99
|
-
rspec-support (3.
|
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
|
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!
|
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:
|
164
|
-
id:
|
165
|
-
association:
|
166
|
-
:
|
167
|
-
|
168
|
-
|
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
|
-
|
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:
|
185
|
-
:
|
186
|
-
|
187
|
-
|
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
|
|
data/bin/console
CHANGED
data/lib/syncify.rb
CHANGED
@@ -2,8 +2,15 @@ require "syncify/version"
|
|
2
2
|
|
3
3
|
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
4
4
|
|
5
|
-
|
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;
|
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,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
|
-
|
23
|
-
|
24
|
-
|
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
|
28
|
-
|
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
|
data/lib/syncify/sync.rb
CHANGED
@@ -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,
|
26
|
+
identify_associated_records(root_record, associations)
|
26
27
|
end
|
27
28
|
end
|
28
29
|
|
29
|
-
puts "
|
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
|
-
|
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
|
-
|
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
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
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
|
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
|
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
|
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
|
-
|
146
|
-
|
147
|
-
|
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.
|
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)
|
data/lib/syncify/version.rb
CHANGED
data/syncify.gemspec
CHANGED
@@ -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", "~>
|
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.
|
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-
|
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: '
|
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: '
|
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
|