synchronisable 0.0.3 → 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +110 -4
- data/TODO.md +23 -15
- data/lib/generators/synchronisable/templates/initializer.rb +4 -0
- data/lib/synchronisable/attribute_mapper.rb +39 -0
- data/lib/synchronisable/context.rb +1 -1
- data/lib/synchronisable/data_builder.rb +58 -0
- data/lib/synchronisable/dsl/associations/association.rb +5 -2
- data/lib/synchronisable/input_descriptor.rb +42 -0
- data/lib/synchronisable/locale/en.yml +3 -3
- data/lib/synchronisable/model.rb +1 -1
- data/lib/synchronisable/source.rb +30 -10
- data/lib/synchronisable/synchronizer.rb +12 -32
- data/lib/synchronisable/version.rb +1 -1
- data/lib/synchronisable/worker.rb +26 -49
- data/lib/synchronisable.rb +7 -1
- data/spec/dummy/app/synchronizers/break_convention_team_synchronizer.rb +2 -0
- data/spec/dummy/app/synchronizers/match_synchronizer.rb +0 -2
- data/spec/dummy/config/application.rb +0 -1
- data/spec/dummy/config/initializers/{synchronizable.rb → synchronisable.rb} +0 -0
- data/spec/factories/import.rb +1 -1
- data/spec/models/team_spec.rb +86 -1
- data/spec/spec_helper.rb +2 -2
- data/spec/synchronisable/support/shared/contexts.rb +33 -0
- data/spec/synchronisable/support/shared/examples.rb +0 -0
- data/synchronisable.gemspec +1 -1
- metadata +68 -47
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 373108e0dab22db4d8c9ff814efc6df6c875a012
|
4
|
+
data.tar.gz: 01d16982d0a8085ed46b69a117b4189ef8d39be1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ecefc4f286f2227d86193e6aa5c16861ca1d2feb668ba874e28dbc0815cae88ba080c41978a57352ac4935f0c08db7bbe7b54b5bc943de60ddaf45263dde2465
|
7
|
+
data.tar.gz: 1a67ad8603495c3b6e41d0a6a95f512ad31f8b65213711f784a671504ffffc242bda2befed97e8ac2b0855f5af7e9dc3842ecd79f9aa1a71411b6c5904400c13
|
data/README.md
CHANGED
@@ -1,15 +1,16 @@
|
|
1
1
|
[![Build Status](https://travis-ci.org/vyorkin/synchronisable.png?branch=master)](https://travis-ci.org/vyorkin/synchronisable)
|
2
2
|
[![Code Climate](https://codeclimate.com/github/vyorkin/synchronisable.png)](https://codeclimate.com/github/vyorkin/synchronisable)
|
3
|
+
[![Inline docs](http://inch-pages.github.io/github/vyorkin/synchronisable.png)](http://inch-pages.github.io/github/vyorkin/synchronisable)
|
3
4
|
[![Coverage Status](https://coveralls.io/repos/vyorkin/synchronisable/badge.png)](https://coveralls.io/r/vyorkin/synchronisable)
|
4
|
-
[![Inch Pages](http://inch-pages.github.io/github/vyorkin/synchronisable)](http://inch-pages.github.io/github/vyorkin/synchronisable)
|
5
5
|
[![Gem Version](http://stillmaintained.com/vyorkin/synchronisable.png)](http://stillmaintained.com/vyorkin/synchronisable)
|
6
6
|
[![Dependency Status](https://gemnasium.com/vyorkin/synchronisable.svg)](https://gemnasium.com/vyorkin/synchronisable)
|
7
7
|
|
8
|
-
|
9
|
-
|
10
8
|
# Synchronisable
|
11
9
|
|
12
|
-
Provides base fuctionality (models, DSL) for AR synchronization
|
10
|
+
Provides base fuctionality (models, DSL) for AR synchronization
|
11
|
+
with external resources (apis, services etc).
|
12
|
+
|
13
|
+
## Overview
|
13
14
|
|
14
15
|
## Installation
|
15
16
|
|
@@ -27,3 +28,108 @@ Or install it yourself as:
|
|
27
28
|
|
28
29
|
## Usage
|
29
30
|
|
31
|
+
For examples we'll be using a well-known domain with posts & comments
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
class Post < ActiveRecord::Base
|
35
|
+
has_many :comments
|
36
|
+
|
37
|
+
synchronisable
|
38
|
+
end
|
39
|
+
|
40
|
+
class Comment < ActiveRecord::Base
|
41
|
+
belongs_to :post
|
42
|
+
|
43
|
+
synchronisable MyCommentSynchronizer
|
44
|
+
end
|
45
|
+
```
|
46
|
+
|
47
|
+
As you can see above the first step is to declare your models to be
|
48
|
+
synchronisable. You can do so by using corresponding dsl instruction,
|
49
|
+
that optionally takes a synchonizer class to be used. Actually,
|
50
|
+
the only reason to specify it its when it has a name, that can't be figured out
|
51
|
+
by the following convention: `ModelSynchronizer`.
|
52
|
+
|
53
|
+
After that you should define your model synchronizers
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
class PostSynchronizer < Synchronisable::Synchronizer
|
57
|
+
remote_id :p_id
|
58
|
+
|
59
|
+
mappings(
|
60
|
+
:t => :title,
|
61
|
+
:c => :content
|
62
|
+
)
|
63
|
+
|
64
|
+
except :ignored_attr1, :ignored_attr42
|
65
|
+
|
66
|
+
has_many :comments
|
67
|
+
|
68
|
+
fetch do
|
69
|
+
# return array of hashes with
|
70
|
+
# remote entity attributes
|
71
|
+
end
|
72
|
+
|
73
|
+
find do |id|
|
74
|
+
# return a hash with
|
75
|
+
# with remote entity attributes
|
76
|
+
end
|
77
|
+
|
78
|
+
# Hooks/callbacks
|
79
|
+
|
80
|
+
before_record_sync do |source|
|
81
|
+
# ...
|
82
|
+
end
|
83
|
+
|
84
|
+
after_record_sync do |source|
|
85
|
+
# ...
|
86
|
+
end
|
87
|
+
|
88
|
+
before_association_sync do |source, remote_id, association|
|
89
|
+
# ...
|
90
|
+
end
|
91
|
+
|
92
|
+
after_association_sync do |source, remote_id, association|
|
93
|
+
# ...
|
94
|
+
end
|
95
|
+
|
96
|
+
before_sync do |source|
|
97
|
+
# ...
|
98
|
+
end
|
99
|
+
|
100
|
+
after_sync do |source|
|
101
|
+
# ...
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
class MyCommentSynchronizer < Synchronisable::Synchronizer
|
106
|
+
remote_id :c_id
|
107
|
+
|
108
|
+
mappings(
|
109
|
+
:a => :author,
|
110
|
+
:t => :body
|
111
|
+
)
|
112
|
+
|
113
|
+
only :author, :body
|
114
|
+
|
115
|
+
fetch do
|
116
|
+
# ...
|
117
|
+
end
|
118
|
+
|
119
|
+
find do |id|
|
120
|
+
# ...
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
```
|
125
|
+
|
126
|
+
To start synchronization
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
Post.sync
|
130
|
+
```
|
131
|
+
P.S.: Better readme & wiki is coming! ^__^
|
132
|
+
|
133
|
+
## Support
|
134
|
+
|
135
|
+
<a href='https://www.codersclan.net/task/yorkinv' target='_blank'><img src='https://www.codersclan.net/button/yorkinv' alt='expert-button' width='205' height='64' style='width: 205px; height: 64px;'></a>
|
data/TODO.md
CHANGED
@@ -1,20 +1,28 @@
|
|
1
1
|
Primary objectives
|
2
2
|
======================================
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
3
|
+
- [x] general tests
|
4
|
+
- [x] except/only
|
5
|
+
- [x] sync method
|
6
|
+
- [x] dependent syncronization & mapping
|
7
|
+
- [x] tests for Synchronisable.sync
|
8
|
+
- [ ] fix a mess with Context
|
9
|
+
- [ ] destroy_missed
|
10
|
+
- [ ] worker.rb refactoring
|
11
|
+
- [x] integrate with travis, stillmaintained, gemnasium,
|
12
|
+
codeclimate, coveralls, inch-pages, codersclan
|
13
|
+
- [ ] write a good README
|
14
|
+
- [ ] extended interface
|
15
|
+
- [ ] sync with include
|
16
|
+
- [x] sync with ids array
|
17
|
+
- [ ] handle case when association type is a :hash
|
18
|
+
- [ ] sync method for collection proxy (Model.where(condition).sync)
|
19
|
+
|
20
|
+
Think about
|
21
|
+
======================================
|
22
|
+
- [ ] has_many :bars, :through => FooModel
|
23
|
+
- [ ] polymorphic associations
|
16
24
|
|
17
25
|
Secondary objectives
|
18
26
|
======================================
|
19
|
-
|
20
|
-
|
27
|
+
- [x] option for verbose logging
|
28
|
+
- [x] colorized STDOUT
|
@@ -1,7 +1,11 @@
|
|
1
1
|
Synchronisable.configure do |config|
|
2
2
|
# Logging configuration
|
3
3
|
#
|
4
|
+
# Default logger fallbacks to `Rails.logger` if available, otherwise
|
5
|
+
# `STDOUT` will be used for output.
|
6
|
+
#
|
4
7
|
# config.logging = {
|
8
|
+
# :logger => defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
|
5
9
|
# :verbose => true,
|
6
10
|
# :colorize => true
|
7
11
|
# }
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Synchronisable
|
2
|
+
class AttributeMapper
|
3
|
+
class << self
|
4
|
+
def map(source, mappings, options = {})
|
5
|
+
new(mappings, options).map(source)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(mappings, options = {})
|
10
|
+
@mappings = mappings
|
11
|
+
@keep = options[:keep] || []
|
12
|
+
@only, @except = options[:only], options[:except]
|
13
|
+
end
|
14
|
+
|
15
|
+
def map(source)
|
16
|
+
result = source.dup
|
17
|
+
|
18
|
+
apply_mappings(result) if @mappings.present?
|
19
|
+
apply_only(result) if @only.present?
|
20
|
+
apply_except(result) if @except.present?
|
21
|
+
|
22
|
+
result
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def apply_mappings(source)
|
28
|
+
source.transform_keys! { |key| @mappings[key] || key }
|
29
|
+
end
|
30
|
+
|
31
|
+
def apply_only(source)
|
32
|
+
source.keep_if { |key| @only.include?(key) || @keep.include?(key) }
|
33
|
+
end
|
34
|
+
|
35
|
+
def apply_except(source)
|
36
|
+
source.delete_if { |key| key.nil? || @except.include?(key) }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'synchronisable/input_descriptor'
|
2
|
+
|
3
|
+
module Synchronisable
|
4
|
+
class DataBuilder
|
5
|
+
class << self
|
6
|
+
def build(model, synchronizer, data)
|
7
|
+
new(model, synchronizer).build(data)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(model, synchronizer)
|
12
|
+
@model = model
|
13
|
+
@synchronizer = synchronizer
|
14
|
+
end
|
15
|
+
|
16
|
+
def build(data)
|
17
|
+
input = InputDescriptor.new(data)
|
18
|
+
|
19
|
+
result = case input
|
20
|
+
when ->(i) { i.empty? }
|
21
|
+
@synchronizer.fetch.()
|
22
|
+
when ->(i) { i.remote_id? }
|
23
|
+
@synchronizer.find.(data)
|
24
|
+
when ->(i) { i.local_id? }
|
25
|
+
find_by_local_id(data)
|
26
|
+
when ->(i) { i.array_of_ids? }
|
27
|
+
find_by_array_of_ids(input)
|
28
|
+
else
|
29
|
+
result = data.dup
|
30
|
+
end
|
31
|
+
|
32
|
+
[result].flatten
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def find_by_array_of_ids(input)
|
38
|
+
records = find_imports(input.element_class.name, input.data)
|
39
|
+
records.map { |r| @synchronizer.find.(r.remote_id) }
|
40
|
+
end
|
41
|
+
|
42
|
+
def find_by_local_id(id)
|
43
|
+
import = @model.find_by(id: id).try(:import)
|
44
|
+
import ? @synchronizer.find.(import.remote_id) : nil
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def find_imports(class_name, ids)
|
50
|
+
case class_name
|
51
|
+
when 'Fixnum'
|
52
|
+
ids.map { |id| @model.find_by(id: id).try(&:import) }
|
53
|
+
when 'String'
|
54
|
+
ids.map { |id| Import.find_by(id: id) }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -17,9 +17,9 @@ module Synchronisable
|
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
20
|
-
self.valid_options = %i(key class_name required)
|
20
|
+
self.valid_options = %i(key class_name required type)
|
21
21
|
|
22
|
-
attr_reader :name, :model, :key, :required
|
22
|
+
attr_reader :name, :model, :key, :required, :type
|
23
23
|
|
24
24
|
def initialize(synchronizer, name)
|
25
25
|
@synchronizer, @name = synchronizer, name.to_sym
|
@@ -30,6 +30,7 @@ module Synchronisable
|
|
30
30
|
|
31
31
|
@key = options[:key]
|
32
32
|
@required = options[:required]
|
33
|
+
@type = options[:type]
|
33
34
|
|
34
35
|
if options[:class_name].present?
|
35
36
|
@model = options[:class_name].constantize
|
@@ -44,6 +45,8 @@ module Synchronisable
|
|
44
45
|
|
45
46
|
def set_defaults
|
46
47
|
@required ||= false
|
48
|
+
@type ||= :ids
|
49
|
+
|
47
50
|
@model ||= @name.to_s.classify.constantize
|
48
51
|
@key = "#{@name}_#{self.class.key_suffix}" unless @key.present?
|
49
52
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Synchronisable
|
2
|
+
class InputDescriptor
|
3
|
+
attr_reader :data
|
4
|
+
|
5
|
+
def initialize(data)
|
6
|
+
@data = data
|
7
|
+
end
|
8
|
+
|
9
|
+
def empty?
|
10
|
+
@data.blank?
|
11
|
+
end
|
12
|
+
|
13
|
+
def remote_id?
|
14
|
+
@data.is_a?(String)
|
15
|
+
end
|
16
|
+
|
17
|
+
def local_id?
|
18
|
+
@data.is_a?(Integer)
|
19
|
+
end
|
20
|
+
|
21
|
+
def array_of_ids?
|
22
|
+
enumerable? && (
|
23
|
+
first_element.is_a?(String) ||
|
24
|
+
first_element.is_a?(Integer)
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def element_class
|
29
|
+
first_element.try(:class)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def first_element
|
35
|
+
@data.try(:first)
|
36
|
+
end
|
37
|
+
|
38
|
+
def enumerable?
|
39
|
+
@data.is_a?(Enumerable)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -12,6 +12,6 @@ en:
|
|
12
12
|
gateway_method_missing: Method %{method} is not implemented for gateway
|
13
13
|
messages:
|
14
14
|
result: |
|
15
|
-
|
16
|
-
|
17
|
-
deleted
|
15
|
+
parent: %{parent},
|
16
|
+
records before: %{before}, records after: %{after},
|
17
|
+
deleted records: %{deleted}, errors: %{errors}
|
data/lib/synchronisable/model.rb
CHANGED
@@ -3,23 +3,29 @@ module Synchronisable
|
|
3
3
|
class Source
|
4
4
|
attr_accessor :import_record
|
5
5
|
attr_reader :model, :remote_attrs,
|
6
|
-
:remote_id, :local_attrs,
|
6
|
+
:remote_id, :local_attrs,
|
7
|
+
:associations, :import_ids
|
7
8
|
|
8
9
|
def initialize(model, parent, remote_attrs)
|
9
10
|
@model, @parent, @synchronizer = model, parent, model.synchronizer
|
10
11
|
@remote_attrs = remote_attrs.with_indifferent_access
|
11
12
|
end
|
12
13
|
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
14
|
+
# Prepares synchronization source:
|
15
|
+
# `remote_id`, `local_attributes`, `import_record` and `associations`.
|
16
|
+
# Sets foreign key if current model is specified as `has_one` or `has_many`
|
17
|
+
# association of parent model.
|
16
18
|
#
|
17
19
|
# @api private
|
18
|
-
def
|
20
|
+
def prepare
|
19
21
|
@remote_id = @synchronizer.extract_remote_id(@remote_attrs)
|
20
22
|
@local_attrs = @synchronizer.map_attributes(@remote_attrs)
|
21
23
|
@associations = @synchronizer.associations_for(@local_attrs)
|
22
24
|
|
25
|
+
# TODO: implement destroy_missed, somehow pass @import_ids,
|
26
|
+
# get all import records if nil
|
27
|
+
|
28
|
+
# remove associations keys from local attributes
|
23
29
|
@local_attrs.delete_if do |key, _|
|
24
30
|
@associations.keys.any? { |a| a.key == key }
|
25
31
|
end
|
@@ -29,20 +35,34 @@ module Synchronisable
|
|
29
35
|
:synchronisable_type => @model
|
30
36
|
)
|
31
37
|
|
32
|
-
|
38
|
+
set_foreign_key
|
33
39
|
end
|
34
40
|
|
35
41
|
def updatable?
|
36
42
|
@import_record.present? && local_record.present?
|
37
43
|
end
|
38
44
|
|
45
|
+
def update_record
|
46
|
+
local_record.update_attributes!(@local_attrs)
|
47
|
+
end
|
48
|
+
|
49
|
+
def create_record_pair
|
50
|
+
record = @model.create!(@local_attrs)
|
51
|
+
@import_record = Import.create!(
|
52
|
+
:synchronisable_id => record.id,
|
53
|
+
:synchronisable_type => @model.to_s,
|
54
|
+
:remote_id => @remote_id,
|
55
|
+
:attrs => @local_attrs
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
39
59
|
def local_record
|
40
60
|
@import_record.try(:synchronisable)
|
41
61
|
end
|
42
62
|
|
43
63
|
def dump_message
|
44
64
|
%Q(
|
45
|
-
|
65
|
+
remote id: '#{remote_id}',
|
46
66
|
remote attributes: #{remote_attrs},
|
47
67
|
local attributes: #{local_attrs}
|
48
68
|
)
|
@@ -50,13 +70,13 @@ module Synchronisable
|
|
50
70
|
|
51
71
|
private
|
52
72
|
|
53
|
-
def
|
73
|
+
def set_foreign_key
|
54
74
|
return unless @parent
|
55
|
-
name =
|
75
|
+
name = foreign_key_name
|
56
76
|
@local_attrs[name] = @parent.local_record.id if name
|
57
77
|
end
|
58
78
|
|
59
|
-
def
|
79
|
+
def foreign_key_name
|
60
80
|
return nil unless parent_has_model_as_reflection?
|
61
81
|
parent_name = @parent.model.table_name.singularize
|
62
82
|
"#{parent_name}_id"
|