rails_sync 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +165 -0
  3. data/README.md +34 -0
  4. data/Rakefile +32 -0
  5. data/app/assets/config/rails_sync_manifest.js +0 -0
  6. data/app/controllers/rails_sync_admin/base_controller.rb +4 -0
  7. data/app/controllers/rails_sync_admin/sync_audits_controller.rb +40 -0
  8. data/app/jobs/audit_apply_job.rb +9 -0
  9. data/app/models/mysql/mysql_column.rb +5 -0
  10. data/app/models/mysql/mysql_engine.rb +5 -0
  11. data/app/models/mysql/mysql_index.rb +5 -0
  12. data/app/models/mysql/mysql_server.rb +5 -0
  13. data/app/models/mysql/mysql_table.rb +5 -0
  14. data/app/models/sync_audit.rb +89 -0
  15. data/app/views/rails_sync_admin/sync_audits/_filter.html.erb +8 -0
  16. data/app/views/rails_sync_admin/sync_audits/_form.html.erb +4 -0
  17. data/app/views/rails_sync_admin/sync_audits/_search_form.html.erb +35 -0
  18. data/app/views/rails_sync_admin/sync_audits/edit.html.erb +9 -0
  19. data/app/views/rails_sync_admin/sync_audits/index.html.erb +57 -0
  20. data/app/views/rails_sync_admin/sync_audits/new.html.erb +9 -0
  21. data/app/views/rails_sync_admin/sync_audits/show.html.erb +0 -0
  22. data/app/views/rails_sync_admin/sync_audits/synchros.html.erb +41 -0
  23. data/config/locales/en.yml +21 -0
  24. data/config/locales/zh.yml +8 -0
  25. data/config/routes.rb +11 -0
  26. data/db/migrate/20180319031059_create_sync_audits.rb +16 -0
  27. data/lib/rails_sync.rb +16 -0
  28. data/lib/rails_sync/active_record.rb +87 -0
  29. data/lib/rails_sync/adapter.rb +46 -0
  30. data/lib/rails_sync/analyzer.rb +119 -0
  31. data/lib/rails_sync/config.rb +10 -0
  32. data/lib/rails_sync/engine.rb +7 -0
  33. data/lib/rails_sync/table.rb +110 -0
  34. data/lib/rails_sync/version.rb +3 -0
  35. data/lib/tasks/the_sync_tasks.rake +4 -0
  36. metadata +92 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 690c52679acf76eca8c80da03e893888a4937ce7ac85341fdd0f895f4e07f1a9
4
+ data.tar.gz: 163e59a515c618404bc010ec61dbac2832a0e71b3a23ab86f49fe3d62ac2d06f
5
+ SHA512:
6
+ metadata.gz: f5ac64f117442021284f15446c2f710e85f2cf66258758e917c8d4f3df078a89160f932e7bd94e1ba1207ad64d1769eaf3b80b6cdbc883749c0ab100046cdfde
7
+ data.tar.gz: 8a6bc5e78d1092dfdcb5a9fb26b09396100bff48c98f59fae42283bbc1cc0723ec9ad5b3f5873dd2f3c2673b8829d0bf14ccdd9dbd6ff4333de5e59083d6f8dd
data/LICENSE ADDED
@@ -0,0 +1,165 @@
1
+ GNU LESSER GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2018 Mingyuan Qin.
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+
9
+ This version of the GNU Lesser General Public License incorporates
10
+ the terms and conditions of version 3 of the GNU General Public
11
+ License, supplemented by the additional permissions listed below.
12
+
13
+ 0. Additional Definitions.
14
+
15
+ As used herein, "this License" refers to version 3 of the GNU Lesser
16
+ General Public License, and the "GNU GPL" refers to version 3 of the GNU
17
+ General Public License.
18
+
19
+ "The Library" refers to a covered work governed by this License,
20
+ other than an Application or a Combined Work as defined below.
21
+
22
+ An "Application" is any work that makes use of an interface provided
23
+ by the Library, but which is not otherwise based on the Library.
24
+ Defining a subclass of a class defined by the Library is deemed a mode
25
+ of using an interface provided by the Library.
26
+
27
+ A "Combined Work" is a work produced by combining or linking an
28
+ Application with the Library. The particular version of the Library
29
+ with which the Combined Work was made is also called the "Linked
30
+ Version".
31
+
32
+ The "Minimal Corresponding Source" for a Combined Work means the
33
+ Corresponding Source for the Combined Work, excluding any source code
34
+ for portions of the Combined Work that, considered in isolation, are
35
+ based on the Application, and not on the Linked Version.
36
+
37
+ The "Corresponding Application Code" for a Combined Work means the
38
+ object code and/or source code for the Application, including any data
39
+ and utility programs needed for reproducing the Combined Work from the
40
+ Application, but excluding the System Libraries of the Combined Work.
41
+
42
+ 1. Exception to Section 3 of the GNU GPL.
43
+
44
+ You may convey a covered work under sections 3 and 4 of this License
45
+ without being bound by section 3 of the GNU GPL.
46
+
47
+ 2. Conveying Modified Versions.
48
+
49
+ If you modify a copy of the Library, and, in your modifications, a
50
+ facility refers to a function or data to be supplied by an Application
51
+ that uses the facility (other than as an argument passed when the
52
+ facility is invoked), then you may convey a copy of the modified
53
+ version:
54
+
55
+ a) under this License, provided that you make a good faith effort to
56
+ ensure that, in the event an Application does not supply the
57
+ function or data, the facility still operates, and performs
58
+ whatever part of its purpose remains meaningful, or
59
+
60
+ b) under the GNU GPL, with none of the additional permissions of
61
+ this License applicable to that copy.
62
+
63
+ 3. Object Code Incorporating Material from Library Header Files.
64
+
65
+ The object code form of an Application may incorporate material from
66
+ a header file that is part of the Library. You may convey such object
67
+ code under terms of your choice, provided that, if the incorporated
68
+ material is not limited to numerical parameters, data structure
69
+ layouts and accessors, or small macros, inline functions and templates
70
+ (ten or fewer lines in length), you do both of the following:
71
+
72
+ a) Give prominent notice with each copy of the object code that the
73
+ Library is used in it and that the Library and its use are
74
+ covered by this License.
75
+
76
+ b) Accompany the object code with a copy of the GNU GPL and this license
77
+ document.
78
+
79
+ 4. Combined Works.
80
+
81
+ You may convey a Combined Work under terms of your choice that,
82
+ taken together, effectively do not restrict modification of the
83
+ portions of the Library contained in the Combined Work and reverse
84
+ engineering for debugging such modifications, if you also do each of
85
+ the following:
86
+
87
+ a) Give prominent notice with each copy of the Combined Work that
88
+ the Library is used in it and that the Library and its use are
89
+ covered by this License.
90
+
91
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
92
+ document.
93
+
94
+ c) For a Combined Work that displays copyright notices during
95
+ execution, include the copyright notice for the Library among
96
+ these notices, as well as a reference directing the user to the
97
+ copies of the GNU GPL and this license document.
98
+
99
+ d) Do one of the following:
100
+
101
+ 0) Convey the Minimal Corresponding Source under the terms of this
102
+ License, and the Corresponding Application Code in a form
103
+ suitable for, and under terms that permit, the user to
104
+ recombine or relink the Application with a modified version of
105
+ the Linked Version to produce a modified Combined Work, in the
106
+ manner specified by section 6 of the GNU GPL for conveying
107
+ Corresponding Source.
108
+
109
+ 1) Use a suitable shared library mechanism for linking with the
110
+ Library. A suitable mechanism is one that (a) uses at run time
111
+ a copy of the Library already present on the user's computer
112
+ system, and (b) will operate properly with a modified version
113
+ of the Library that is interface-compatible with the Linked
114
+ Version.
115
+
116
+ e) Provide Installation Information, but only if you would otherwise
117
+ be required to provide such information under section 6 of the
118
+ GNU GPL, and only to the extent that such information is
119
+ necessary to install and execute a modified version of the
120
+ Combined Work produced by recombining or relinking the
121
+ Application with a modified version of the Linked Version. (If
122
+ you use option 4d0, the Installation Information must accompany
123
+ the Minimal Corresponding Source and Corresponding Application
124
+ Code. If you use option 4d1, you must provide the Installation
125
+ Information in the manner specified by section 6 of the GNU GPL
126
+ for conveying Corresponding Source.)
127
+
128
+ 5. Combined Libraries.
129
+
130
+ You may place library facilities that are a work based on the
131
+ Library side by side in a single library together with other library
132
+ facilities that are not Applications and are not covered by this
133
+ License, and convey such a combined library under terms of your
134
+ choice, if you do both of the following:
135
+
136
+ a) Accompany the combined library with a copy of the same work based
137
+ on the Library, uncombined with any other library facilities,
138
+ conveyed under the terms of this License.
139
+
140
+ b) Give prominent notice with the combined library that part of it
141
+ is a work based on the Library, and explaining where to find the
142
+ accompanying uncombined form of the same work.
143
+
144
+ 6. Revised Versions of the GNU Lesser General Public License.
145
+
146
+ The Free Software Foundation may publish revised and/or new versions
147
+ of the GNU Lesser General Public License from time to time. Such new
148
+ versions will be similar in spirit to the present version, but may
149
+ differ in detail to address new problems or concerns.
150
+
151
+ Each version is given a distinguishing version number. If the
152
+ Library as you received it specifies that a certain numbered version
153
+ of the GNU Lesser General Public License "or any later version"
154
+ applies to it, you have the option of following the terms and
155
+ conditions either of that published version or of any later version
156
+ published by the Free Software Foundation. If the Library as you
157
+ received it does not specify a version number of the GNU Lesser
158
+ General Public License, you may choose any version of the GNU Lesser
159
+ General Public License ever published by the Free Software Foundation.
160
+
161
+ If the Library as you received it specifies that a proxy can decide
162
+ whether future versions of the GNU Lesser General Public License shall
163
+ apply, that proxy's public statement of acceptance of any version is
164
+ permanent authorization for you to choose that version for the
165
+ Library.
data/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # RailsSync
2
+
3
+ * [English](./README.en.md)
4
+
5
+
6
+ ## 适用场景
7
+ 1. 应用之间同步数据;
8
+ 2. 新老应用间的数据迁移;
9
+ 2. 两个 `table` 字段名称可能不同;
10
+ 3. 两个 `table` 基于唯一的id进行同步数据,id可能是主键,也可能不是;
11
+
12
+ ## 注意
13
+ 1. 如果同一个表,两边应用都对其同一个字段有内容修改,业务上会比较混乱,建议避免;
14
+ 2. 由于是直接数据库层面同步数据,不容易在目标应用内直接触发业务逻辑,可在本应用内基于Audit触发业务逻辑;
15
+
16
+ ## 特性
17
+ 1. 绝对靠谱,不依赖binlog,基于主键(支持联合主键)对两个表的各个字段进行比较,找出字段值的变化,新增的记录,删除的记录;
18
+ 2. 高性能,10万以内的计算毫秒级;
19
+ 3. 兼容性强,可支持各个关系型数据库(依赖 linked DB, mysql FEDERATED引擎等);
20
+ 4. 安全,对数据的改变先记录而不应用改变,可基于记录人工决定是否应用该改变,或者revert相应的改动;
21
+ 5. 实时性强,可以通过应用里的数据改变动作触发;
22
+ 6. 可以同步多个来源的数据;
23
+
24
+ ## 实现原理
25
+ 1. 处理表;
26
+ a. 对于不同节点,建立临时表,映射远程数据库节点中的对应表(只映射需要比较改变值的字段),在mysql中采用`FEDERATED`引擎;
27
+ 2. 通过 inner_join 计算两张表不一样的字段;
28
+ 3. 通过 left_join 找出左表多出的记录;
29
+ 4. 通过 right_join 找出右表多出的记录;
30
+
31
+ ## Remote Table
32
+ * postgresql: [dblink](https://www.postgresql.org/docs/10/static/dblink.html)
33
+ * mysql: [FEDERATED](https://dev.mysql.com/doc/refman/5.7/en/federated-storage-engine.html)
34
+ * mariadb: [CONNECT](https://mariadb.com/kb/en/library/connect/)
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'RailsSync'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ load 'rails/tasks/statistics.rake'
21
+
22
+ require 'bundler/gem_tasks'
23
+
24
+ require 'rake/testtask'
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << 'test'
28
+ t.pattern = 'test/**/*_test.rb'
29
+ t.verbose = false
30
+ end
31
+
32
+ task default: :test
File without changes
@@ -0,0 +1,4 @@
1
+ class RailsSyncAdmin::BaseController < RailsSync.config.admin_class.constantize
2
+
3
+
4
+ end
@@ -0,0 +1,40 @@
1
+ class RailsSyncAdmin::SyncAuditsController < RailsSyncAdmin::BaseController
2
+ before_action :set_sync_audit, only: [:show, :apply, :destroy]
3
+
4
+ def index
5
+ q_params = params.permit(:synchro_type, :state, :operation)
6
+ @sync_audits = SyncAudit.default_where(q_params).order(id: :desc).page(params[:page])
7
+ @synchro_types = RailsSync.synchro_types.uniq | SyncAudit.synchro_types
8
+ end
9
+
10
+ def sync
11
+ @synchro_model = params[:synchro_type].constantize
12
+ @synchro_model.cache_all_diffs
13
+
14
+ redirect_to admin_sync_audits_url, notice: 'Sync Run successfully '
15
+ end
16
+
17
+ def batch
18
+ AuditApplyJob.perform_later params[:synchro_type]
19
+ redirect_to admin_sync_audits_url, notice: 'Batch Apply Sync Run successfully in backend'
20
+ end
21
+
22
+ def show
23
+ end
24
+
25
+ def apply
26
+ @sync_audit.apply_changes
27
+ redirect_to admin_sync_audits_url, notice: 'Sync audit was successfully applied.'
28
+ end
29
+
30
+ def destroy
31
+ @sync_audit.destroy
32
+ redirect_to admin_sync_audits_url, notice: 'Sync audit was successfully destroyed.'
33
+ end
34
+
35
+ private
36
+ def set_sync_audit
37
+ @sync_audit = SyncAudit.find(params[:id])
38
+ end
39
+
40
+ end
@@ -0,0 +1,9 @@
1
+ class AuditApplyJob < ActiveJob::Base
2
+ queue_as :default
3
+
4
+ def perform(synchro_type, operation: ['update', 'insert', 'delete'])
5
+ SyncAudit.apply_synchro(synchro_type, operation: operation)
6
+ SyncAudit.apply_callback(synchro_type, operation: operation)
7
+ end
8
+
9
+ end
@@ -0,0 +1,5 @@
1
+ class MysqlColumn < ApplicationRecord
2
+ self.establish_connection connection_config.merge(database: 'information_schema')
3
+ self.table_name = 'COLUMNS'
4
+
5
+ end
@@ -0,0 +1,5 @@
1
+ class MysqlTable < ApplicationRecord
2
+ self.establish_connection connection_config.merge(database: 'information_schema')
3
+ self.table_name = 'TABLES'
4
+
5
+ end
@@ -0,0 +1,5 @@
1
+ class MysqlIndex < ApplicationRecord
2
+ self.establish_connection connection_config.merge(database: 'information_schema')
3
+ self.table_name = 'STATISTICS'
4
+
5
+ end
@@ -0,0 +1,5 @@
1
+ class MysqlServer < ApplicationRecord
2
+ self.establish_connection connection_config.merge(database: 'mysql')
3
+ self.table_name = 'servers'
4
+
5
+ end
@@ -0,0 +1,5 @@
1
+ class MysqlTable < ApplicationRecord
2
+ self.establish_connection connection_config.merge(database: 'information_schema')
3
+ self.table_name = 'ENGINES'
4
+
5
+ end
@@ -0,0 +1,89 @@
1
+ class SyncAudit < ApplicationRecord
2
+ serialize :audited_changes, Hash
3
+ serialize :synchro_params, Hash
4
+
5
+ enum state: {
6
+ init: 'init',
7
+ applied: 'applied',
8
+ finished: 'finished'
9
+ }
10
+
11
+ enum operation: {
12
+ update: 'update',
13
+ delete: 'delete',
14
+ insert: 'insert'
15
+ }, _prefix: true
16
+
17
+ belongs_to :synchro, polymorphic: true, optional: true
18
+ belongs_to :operator, polymorphic: true, optional: true
19
+
20
+ after_initialize if: :new_record? do
21
+ self.state = 'init'
22
+ end
23
+
24
+ def apply_changes
25
+ if self.operation_update?
26
+ _synchro = self.synchro || synchro_model.find_by(synchro_params)
27
+ _synchro.assign_attributes to_apply_params
28
+ self.state = 'applied'
29
+ self.synchro_id = _synchro.id
30
+ self.class.transaction do
31
+ _synchro.save!
32
+ self.save!
33
+ end
34
+ elsif self.operation_delete? && self.synchro
35
+ self.class.transaction do
36
+ self.synchro.destroy!
37
+ self.update! state: 'applied'
38
+ end
39
+ elsif self.operation_insert?
40
+ _synchro = synchro_model.find_or_initialize_by(synchro_params)
41
+ _synchro.assign_attributes to_apply_params
42
+ self.class.transaction do
43
+ _synchro.save_sneakily!
44
+ self.update! synchro_id: _synchro.id, state: 'applied'
45
+ end
46
+ end
47
+ end
48
+
49
+ def synchro_model
50
+ @synchro_model ||= self.synchro_type.constantize
51
+ end
52
+
53
+ def to_apply_params
54
+ x = {}
55
+ audited_changes.each do |key, v|
56
+ if synchro_model.columns_hash[key].type == :string
57
+ x[key] = v[1].to_s
58
+ else
59
+ x[key] = v[1]
60
+ end
61
+ end
62
+
63
+ x
64
+ end
65
+
66
+ def self.synchro_types
67
+ SyncAudit.select(:synchro_type).distinct.pluck(:synchro_type).compact
68
+ end
69
+
70
+ def self.apply_callback(type, operation: ['update', 'delete', 'insert'])
71
+ SyncAudit.where(state: :applied, synchro_type: type, operation: operation).find_each do |sync|
72
+ SyncAudit.transaction do
73
+ sync.synchro.respond_to?(:after_sync) && sync.synchro.after_sync
74
+ sync.update! state: 'finished'
75
+ end
76
+ end
77
+ end
78
+
79
+ def self.apply_synchro(type, operation: ['update', 'delete', 'insert'])
80
+ SyncAudit.where(state: 'init', synchro_type: type, operation: operation).find_each do |sync_audit|
81
+ begin
82
+ sync_audit.apply_changes
83
+ rescue SystemStackError, ActiveRecord::ActiveRecordError => e
84
+ logger.warn e.message
85
+ end
86
+ end
87
+ end
88
+
89
+ end
@@ -0,0 +1,8 @@
1
+ <div class="ui top attached segment">
2
+ <% if params[:synchro_type] %>
3
+ <%= link_to 'Sync', sync_admin_sync_audits_path(synchro_type: params[:synchro_type]), method: :post, class: 'ui blue button' %>
4
+ <%= link_to 'Batch Apply', batch_admin_sync_audits_path(synchro_type: params[:synchro_type]), method: :post, class: 'ui yellow button' %>
5
+ <% end %>
6
+ </div>
7
+
8
+
@@ -0,0 +1,4 @@
1
+ <%= form_with model: @sync_audit, local: true do |f| %>
2
+ <%= render 'shared/error_messages', target: @sync_audit %>
3
+ <%= f.submit %>
4
+ <% end %>
@@ -0,0 +1,35 @@
1
+ <%= search_form_with do |f| %>
2
+ <div class="fields">
3
+ <%= f.submit 'Search' %>
4
+ </div>
5
+ <% end %>
6
+
7
+ <dl>
8
+ <dt>Synchro Type:</dt>
9
+ <dd>
10
+ <%= link_to 'All', filter_params(except: [:synchro_type]), class: active_params(synchro_type: '', active_class: 'ui basic blue button', item_class: 'ui basic white button') %>
11
+ <% @synchro_types.each do |synchro_type| %>
12
+ <%= link_to SyncAudit.enum_i18n(:synchro_type, synchro_type), filter_params(synchro_type: synchro_type), class: active_params(synchro_type: synchro_type, active_class: 'ui basic blue button', item_class: 'ui basic white button') %>
13
+ <% end %>
14
+ </dd>
15
+ </dl>
16
+
17
+ <dl>
18
+ <dt>State:</dt>
19
+ <dd>
20
+ <%= link_to 'All', filter_params(except: [:state]), class: active_params(state: '', active_class: 'ui basic blue button', item_class: 'ui basic white button') %>
21
+ <% ['init', 'applied', 'finished'].each do |state| %>
22
+ <%= link_to SyncAudit.enum_i18n(:state, state), filter_params(state: state), class: active_params(state: state, active_class: 'ui basic blue button', item_class: 'ui basic white button') %>
23
+ <% end %>
24
+ </dd>
25
+ </dl>
26
+
27
+ <dl>
28
+ <dt>Action:</dt>
29
+ <dd>
30
+ <%= link_to 'All', filter_params(except: [:operation]), class: active_params(operation: '', active_class: 'ui basic blue button', item_class: 'ui basic white button') %>
31
+ <% ['update', 'insert', 'delete'].each do |operation| %>
32
+ <%= link_to SyncAudit.enum_i18n(:operation, operation), filter_params(operation: operation), class: active_params(operation: operation, active_class: 'ui basic blue button', item_class: 'ui basic white button') %>
33
+ <% end %>
34
+ </dd>
35
+ </dl>
@@ -0,0 +1,9 @@
1
+ <div class="ui segment breadcrumb">
2
+ <%= link_to 'Back', sync_audits_path, class: 'section' %>
3
+ <div class="divider"> / </div>
4
+ <div class="active section">Edit</div>
5
+ </div>
6
+
7
+ <div class="ui segment">
8
+ <%= render 'form' %>
9
+ </div>
@@ -0,0 +1,57 @@
1
+ <div class="ui top attached borderless menu">
2
+ <div class="header item">Sync Audits</div>
3
+ </div>
4
+
5
+ <div class="ui attached segment">
6
+ <%= render 'search_form' %>
7
+ </div>
8
+
9
+ <%= render 'filter' %>
10
+ <table class="ui bottom attached fixed table">
11
+ <thead>
12
+ <tr>
13
+ <th class="two wide"><%= SyncAudit.human_attribute_name(:id) %></th>
14
+ <th class="two wide">
15
+ <p><%= SyncAudit.human_attribute_name(:synchro_type) %></p>
16
+ <p><%= SyncAudit.human_attribute_name(:synchro_id) %></p>
17
+ </th>
18
+ <th class="two wide">
19
+ <%= SyncAudit.human_attribute_name(:synchro_params) %>
20
+ </th>
21
+ <th class="one wide"><%= SyncAudit.human_attribute_name(:action) %></th>
22
+ <th class="seven wide"><%= SyncAudit.human_attribute_name(:audited_changes) %></th>
23
+ <th class="two wide">
24
+ <%= SyncAudit.human_attribute_name(:state) %>
25
+ </th>
26
+ </tr>
27
+ </thead>
28
+
29
+ <tbody>
30
+ <% @sync_audits.each do |sync_audit| %>
31
+ <tr>
32
+ <td><%= sync_audit.id %></td>
33
+ <td>
34
+ <p><%= sync_audit.synchro_type %></p>
35
+ <p><%= sync_audit.synchro_id %></p>
36
+ </td>
37
+ <td><%= simple_format(sync_audit.synchro_params) %></td>
38
+ <td><%= sync_audit.operation_i18n %></td>
39
+ <td><%= simple_format_hash sync_audit.audited_changes %></td>
40
+ <td class="ui labels">
41
+ <span class="ui label"><%= sync_audit.state_i18n %></span>
42
+ <% unless sync_audit.applied? %>
43
+ <%= link_to 'apply', apply_admin_sync_audit_path(sync_audit), method: :patch, data: {confirm: 'Are you sure?'}, class: 'ui yellow label' %>
44
+ <% end %>
45
+ <%= link_to admin_sync_audit_path(sync_audit), class: 'ui mini blue icon button' do %>
46
+ <i class="pencil alternate icon"></i>
47
+ <% end %>
48
+ <%= link_to admin_sync_audit_path(sync_audit), method: :delete, data: {confirm: 'Are you sure?'}, class: 'ui mini red icon button' do %>
49
+ <i class="times icon"></i>
50
+ <% end %>
51
+ </td>
52
+ </tr>
53
+ <% end %>
54
+ </tbody>
55
+ </table>
56
+
57
+ <%= paginate @sync_audits %>
@@ -0,0 +1,9 @@
1
+ <div class="ui segment breadcrumb">
2
+ <%= link_to 'Back', sync_audits_path, class: 'section' %>
3
+ <div class="divider"> / </div>
4
+ <div class="active section">Add</div>
5
+ </div>
6
+
7
+ <div class="ui segment">
8
+ <%= render 'form' %>
9
+ </div>
@@ -0,0 +1,41 @@
1
+ <div class="ui top attached borderless menu">
2
+ <div class="header item">Sync Audits</div>
3
+ </div>
4
+
5
+ <div class="ui attached segment">
6
+ <%= render 'search_form' %>
7
+ </div>
8
+
9
+ <table class="ui bottom attached table">
10
+ <thead>
11
+ <tr>
12
+ <th><%= SyncAudit.human_attribute_name(:id) %></th>
13
+ <th><%= SyncAudit.human_attribute_name(:synchro_type) %></th>
14
+ <th><%= SyncAudit.human_attribute_name(:synchro_id) %></th>
15
+ <th><%= SyncAudit.human_attribute_name(:action) %></th>
16
+ <th><%= SyncAudit.human_attribute_name(:audited_changes) %></th>
17
+ <th><%= SyncAudit.human_attribute_name(:state) %></th>
18
+ <th>Actions</th>
19
+ </tr>
20
+ </thead>
21
+
22
+ <tbody>
23
+ <% @sync_types.each do |sync_type| %>
24
+ <tr>
25
+ <td><%= sync_type %></td>
26
+ <td><%= sync_audit.synchro_type %></td>
27
+ <td><%= sync_audit.synchro_id %></td>
28
+ <td><%= sync_audit.operation_i18n %></td>
29
+ <td><%= simple_format_hash sync_audit.audited_changes %></td>
30
+ <td><%= sync_audit.state_i18n %></td>
31
+ <td>
32
+ <%= link_to 'apply', apply_admin_sync_audit_path(sync_audit), method: :patch, data: { confirm: 'Are you sure?' }, class: 'ui yellow label' %>
33
+ <%= link_to 'Show', admin_sync_audit_path(sync_audit), class: 'ui blue label' %>
34
+ <%= link_to 'Destroy', admin_sync_audit_path(sync_audit), method: :delete, data: { confirm: 'Are you sure?' }, class: 'ui red label' %>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+
41
+ <%= paginate @sync_audits %>
@@ -0,0 +1,21 @@
1
+ en:
2
+ the_trade_sync:
3
+ payments:
4
+ index:
5
+ add_new: add
6
+ import: Import
7
+ init: 预核销
8
+ confirmed: 未预核销
9
+ item:
10
+ edit: Edit
11
+ destroy: Destroy
12
+ show: 支付方式
13
+ checking: 核销
14
+ analyze: 分析支付方式
15
+ adjust: 平账
16
+ activerecord:
17
+ attributes:
18
+ sync_audit/state:
19
+ init: Pending
20
+ applied: Applied
21
+ finished: Finished
@@ -0,0 +1,8 @@
1
+ zh:
2
+ rails_sync_admin:
3
+ activerecord:
4
+ attributes:
5
+ sync_audit/state:
6
+ init: Init
7
+ applied: 已修改
8
+ finished: 完成
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ Rails.application.routes.draw do
2
+
3
+ scope :admin, as: 'admin', module: 'rails_sync_admin' do
4
+ resources :sync_audits do
5
+ post :sync, on: :collection
6
+ post :batch, on: :collection
7
+ patch :apply, on: :member
8
+ end
9
+ end
10
+
11
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSyncAudits < ActiveRecord::Migration[5.2]
2
+ def change
3
+ create_table :sync_audits do |t|
4
+ t.references :operator, polymorphic: true
5
+ t.references :synchro, polymorphic: true
6
+ t.references :destined
7
+ t.string :synchro_params
8
+ t.string :operation
9
+ t.string :audited_changes, limit: 4096
10
+ t.string :note, limit: 1024
11
+ t.string :state
12
+ t.datetime :apply_at
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
data/lib/rails_sync.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'rails_sync/engine'
2
+ require 'rails_sync/config'
3
+
4
+ require 'rails_sync/active_record'
5
+ require 'rails_sync/adapter'
6
+
7
+ module RailsSync
8
+ mattr_accessor :synchro_types do
9
+ []
10
+ end
11
+
12
+ def self.options
13
+ @options ||= Rails.application.config_for('rails_sync').with_indifferent_access
14
+ end
15
+
16
+ end
@@ -0,0 +1,87 @@
1
+ require 'rails_sync/adapter'
2
+ require 'rails_sync/table'
3
+ require 'rails_sync/analyzer'
4
+
5
+ module RailsSync::ActiveRecord
6
+ # source
7
+ # source_client
8
+ # dest_table
9
+ def acts_as_sync(options = {})
10
+ @syncs ||= []
11
+
12
+ options[:dest_table] ||= self.table_name
13
+ _mappings = Array(options.delete(:mapping)).to_h
14
+ if options[:only]
15
+ _filter_columns = self.column_names & Array(options.delete(:only))
16
+ else
17
+ _filter_columns = self.column_names - Array(options.delete(:except))
18
+ end
19
+ options[:primary_key] = Array(options[:primary_key] || self.primary_key).map { |i| i.to_s }
20
+ options[:dest_primary_key] = Array(options[:dest_primary_key] || options[:primary_key]).map { |i| i.to_s }
21
+ options[:dest_conditions] = options[:dest_conditions]
22
+ options[:full_mappings] = _filter_columns.map { |column_name|
23
+ next if options[:primary_key].include?(column_name)
24
+ if _mappings.key?(column_name)
25
+ [column_name, _mappings[column_name]]
26
+ else
27
+ [column_name, column_name]
28
+ end
29
+ }.compact
30
+
31
+ options[:server_id] = self.server_id
32
+ options[:analyzer] = RailsSync::Analyzer.new(record: self, **options)
33
+
34
+ RailsSync.synchro_types << self.name
35
+ @syncs << options
36
+ end
37
+
38
+ def server_id
39
+ begin
40
+ result = connection.query('select @@server_uuid')
41
+ rescue
42
+ result = connection.query('select @@server_id')
43
+ end
44
+ _id = result.to_a.flatten.first
45
+ if _id.is_a?(Hash)
46
+ _id.values.first
47
+ else
48
+ _id
49
+ end
50
+ end
51
+
52
+ def analyze_diffs(type = 'update')
53
+ @syncs.flat_map do |options|
54
+ next if !options[:primary_key].include?(self.primary_key) && type == 'delete'
55
+ options[:analyzer].analyze_diffs(type)
56
+ end
57
+ end
58
+
59
+ def cache_diffs(type = 'update')
60
+ @syncs.flat_map do |options|
61
+ next if !options[:primary_key].include?(self.primary_key) && type == 'delete'
62
+ options[:analyzer].cache_diffs(type)
63
+ end
64
+ end
65
+
66
+ def cache_all_diffs(*types)
67
+ types = ['update', 'insert', 'delete'] if types.blank?
68
+ types.each do |type|
69
+ cache_diffs(type)
70
+ end
71
+ end
72
+
73
+ def id_insert?
74
+ @syncs.find { |i| i[:primary_key] == ['id'] }.present?
75
+ end
76
+
77
+ def prepare_sync
78
+ @syncs.flat_map do |options|
79
+ options[:analyzer].reset_temp_table unless options[:analyzer].same_server?
80
+ end
81
+ end
82
+
83
+ end
84
+
85
+ ActiveSupport.on_load :active_record do
86
+ extend RailsSync::ActiveRecord
87
+ end
@@ -0,0 +1,46 @@
1
+ module RailsSync
2
+ class Adapter
3
+ extend ActiveRecord::ConnectionHandling
4
+ mattr_accessor :connection_handler, instance_writer: false
5
+ self.connection_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
6
+
7
+ def initialize(spec, options = {})
8
+ @adapter_options = RailsSync.options.fetch(spec, {})
9
+ @adapter_options[:name] = spec
10
+
11
+ begin
12
+ self.connection
13
+ rescue
14
+ client = self.class.connection_handler.establish_connection(@adapter_options)
15
+ puts "established connection: #{client}"
16
+ end
17
+ end
18
+
19
+ def retrieve_connection
20
+ self.class.connection_handler.retrieve_connection(@adapter_options[:name])
21
+ end
22
+
23
+ def server_id
24
+ begin
25
+ result = connection.query('select @@server_uuid')
26
+ rescue
27
+ result = connection.query('select @@server_id')
28
+ end
29
+ _id = result.to_a.flatten.first
30
+ if _id.is_a?(Hash)
31
+ _id.values.first
32
+ else
33
+ _id
34
+ end
35
+ end
36
+
37
+ def connection
38
+ retrieve_connection
39
+ end
40
+
41
+ def url
42
+ @url ||= "mysql://#{@adapter_options[:username]}:#{@adapter_options[:password]}@#{@adapter_options[:host]}:#{@adapter_options[:port]}/#{@adapter_options[:database]}"
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,119 @@
1
+ class RailsSync::Analyzer
2
+ include RailsSync::Table
3
+ attr_reader :adapter, :my_arel_table, :dest_arel_table, :synchro_type
4
+
5
+ def initialize(options = {})
6
+ @adapter = RailsSync::Adapter.new(options[:dest])
7
+ @dest = options[:dest]
8
+ @record = options[:record]
9
+ @server_id = options[:server_id]
10
+
11
+ @synchro_type = @record.name
12
+ @table_name = @record.table_name
13
+ @dest_table = options[:dest_table]
14
+ @primary_key = options[:primary_key]
15
+ @dest_primary_key = options[:dest_primary_key]
16
+ @dest_conditions = Hash(options[:dest_conditions])
17
+
18
+ @full_mappings = options[:full_mappings]
19
+ @my_columns = @primary_key + @full_mappings.map { |col| col[0] }
20
+ @dest_columns = @dest_primary_key + @full_mappings.map { |col| col[1] }
21
+
22
+ instance_table
23
+ @my_arel_table ||= Arel::Table.new(@table_name)
24
+ @dest_arel_table ||= Arel::Table.new(@dest_table_name, as: 't1')
25
+ end
26
+
27
+ def connection
28
+ @record.connection
29
+ end
30
+
31
+ def skip_analyze?(type)
32
+ ( type == 'delete' && !@primary_key.include?(@record.primary_key) ) ||
33
+ ( type == 'insert' && @record.id_insert? && !@primary_key.include?(@record.primary_key) )
34
+ end
35
+
36
+ def cache_diffs(type = 'update')
37
+ analyze_diffs(type).each do |diff|
38
+ audit = SyncAudit.new synchro_type: synchro_type
39
+
40
+ _params = {}
41
+ @primary_key.each do |primary_key|
42
+ _params[primary_key] = diff.delete(primary_key).compact.first
43
+ end
44
+
45
+ audit.synchro_params = _params
46
+ audit.synchro_id = _params['id']
47
+
48
+ audit.operation = type
49
+ audit.audited_changes = diff
50
+ begin
51
+ audit.save
52
+ rescue ActiveRecord::ValueTooLong => e # todo not require active record
53
+ puts e.message
54
+ end
55
+ end
56
+ end
57
+
58
+ def analyze_diffs(type = 'update')
59
+ return [] if skip_analyze?(type)
60
+ sql = fetch_diffs(type)
61
+ results = connection.execute(sql)
62
+ fields = results.fields.in_groups(2).first
63
+ results.map do |result|
64
+ r = result.in_groups(2)
65
+ hash_value = fields.zip( r[0].zip(r[1]) ).to_h
66
+ hash_value.select do |key, v|
67
+ v[0].to_s != v[1].to_s || @primary_key.include?(key)
68
+ end
69
+ end
70
+ end
71
+
72
+ def fetch_diffs(type = 'update')
73
+ if type == 'update'
74
+ query = analyze_table.join(dest_arel_table).on(on_conditions)
75
+ query.where(analyze_conditions)
76
+ elsif type == 'insert'
77
+ query = analyze_table.join(dest_arel_table, Arel::Nodes::RightOuterJoin).on(on_conditions)
78
+ query.where(my_arel_table[@primary_key[0]].eq(nil))
79
+ elsif type == 'delete'
80
+ query = analyze_table.join(dest_arel_table, Arel::Nodes::OuterJoin).on(on_conditions)
81
+ query.where(my_arel_table[@primary_key[0]].not_eq(nil).and(dest_arel_table[@dest_primary_key[0]].eq(nil)))
82
+ else
83
+ query = analyze_table.join(dest_arel_table, Arel::Nodes::FullOuterJoin).on(on_conditions)
84
+ end
85
+
86
+ query.where(where_conditions) if where_conditions.present?
87
+ query.to_sql
88
+ end
89
+
90
+ def analyze_table
91
+ attrs = @my_columns.map { |col| my_arel_table[col] }
92
+ attrs += @dest_columns.map { |col| dest_arel_table[col] }
93
+ my_arel_table.project(*attrs)
94
+ end
95
+
96
+ def analyze_conditions
97
+ mappings = @full_mappings.map do |mapping|
98
+ my_arel_table[mapping[0]].not_eq(dest_arel_table[mapping[1]])
99
+ end
100
+ Arel::Nodes::SqlLiteral.new mappings.map(&:to_sql).join(' OR ')
101
+ end
102
+
103
+ def on_conditions
104
+ mappings = @primary_key.map.with_index do |left_key, index|
105
+ my_arel_table[left_key].eq(dest_arel_table[@dest_primary_key[index]])
106
+ end
107
+ Arel::Nodes::SqlLiteral.new mappings.map(&:to_sql).join(' AND ')
108
+ end
109
+
110
+ def where_conditions
111
+ cons = @dest_conditions.map do |key, value|
112
+ col, meth = key.to_s.split('-')
113
+ dest_arel_table[col].send meth, value
114
+ end
115
+
116
+ Arel::Nodes::SqlLiteral.new cons.map(&:to_sql).join(' AND ')
117
+ end
118
+
119
+ end
@@ -0,0 +1,10 @@
1
+ require 'active_support/configurable'
2
+
3
+ module RailsSync
4
+ include ActiveSupport::Configurable
5
+
6
+ configure do |config|
7
+ config.admin_class = 'Admin::BaseController'
8
+ end
9
+
10
+ end
@@ -0,0 +1,7 @@
1
+ module RailsSync
2
+ class Engine < ::Rails::Engine
3
+
4
+ config.eager_load_paths += Dir["#{config.root}/app/models/mysql"]
5
+
6
+ end
7
+ end
@@ -0,0 +1,110 @@
1
+ module RailsSync
2
+ module Table
3
+ attr_reader :dest_table_name
4
+
5
+ def instance_table
6
+ if same_server?
7
+ # `source.table_name`
8
+ @dest_table_name = @adapter.instance_variable_get(:@adapter_options)[:database].to_s + '.' + @dest_table.to_s
9
+ else
10
+ # `source_table_name`
11
+ @dest_table_name = @dest.to_s + '_' + @table_name + '-' + @dest_table.to_s
12
+ end
13
+ end
14
+
15
+ def same_server?
16
+ @server_id == adapter.server_id
17
+ end
18
+
19
+ def dest_columns
20
+ adapter.connection.columns(@dest_table)
21
+ end
22
+
23
+ def dest_indexes
24
+ results = adapter.connection.indexes(@dest_table)
25
+ results = results.map { |result| { result.name => result.columns } }
26
+ results.to_combined_hash # rails_com core ext
27
+ results
28
+ end
29
+
30
+ def dest_primary_key
31
+ adapter.connection.primary_key(@dest_table)
32
+ end
33
+
34
+ def dest_sql_table(only: [], except: [], pure: true)
35
+ if only.size > 0
36
+ _columns = dest_columns.select { |column| only.include?(column.name) }
37
+ else
38
+ _columns = dest_columns.reject { |column| except.include?(column.name) }
39
+ end
40
+
41
+ if pure
42
+ sql = ""
43
+ else
44
+ sql = "CREATE TABLE `#{@dest_table}` (\n"
45
+ end
46
+
47
+ _columns.each do |column|
48
+ sql << " `#{column.name}` #{column.sql_type}"
49
+ sql << " COLLATE #{column.collation}" if column.collation.present?
50
+ sql << " NOT NULL" if column.null.is_a?(FalseClass)
51
+ if column.default
52
+ sql << " DEFAULT '#{column.default}',\n"
53
+ elsif column.default.nil? && column.null
54
+ sql << " DEFAULT NULL,\n"
55
+ else
56
+ sql << ",\n"
57
+ end
58
+ end
59
+
60
+ sql << " PRIMARY KEY (`#{dest_primary_key}`)"
61
+
62
+ _indexes = dest_indexes.reject { |_, value| (Array(value) & _columns.map { |col| col.name }).blank? }
63
+
64
+ if _indexes.present?
65
+ sql << ",\n"
66
+ else
67
+ sql << "\n"
68
+ end
69
+ _indexes.each do |index, columns|
70
+ sql << " KEY `#{index}` ("
71
+ sql << Array(columns).map { |col| "`#{col}`" }.join(',')
72
+ sql << "),\n"
73
+ end
74
+
75
+ sql.chomp!(",\n")
76
+
77
+ if pure
78
+ sql
79
+ else
80
+ sql << ")"
81
+ end
82
+ end
83
+
84
+ def reset_temp_table
85
+ drop_temp_table
86
+ create_temp_table
87
+ end
88
+
89
+ def create_temp_table
90
+ unless @dest_columns.include?(dest_primary_key)
91
+ @dest_columns.unshift dest_primary_key
92
+ end
93
+
94
+ sql = "CREATE TABLE `#{@dest_table_name}` (\n"
95
+ sql << dest_sql_table(only: @dest_columns)
96
+ sql << ")"
97
+ sql << "ENGINE=FEDERATED\n"
98
+ sql << "CONNECTION='#{adapter.url}/#{@dest_table}';"
99
+
100
+ connection.execute(sql)
101
+ end
102
+
103
+ def drop_temp_table
104
+ sql = "DROP TABLE IF EXISTS `#{@dest_table_name}`"
105
+
106
+ connection.execute(sql)
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ module RailsSync
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :rails_sync do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_sync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - qinmingyuan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-09-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ description: Description of RailsSync.
28
+ email:
29
+ - mingyuan0715@foxmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - app/assets/config/rails_sync_manifest.js
38
+ - app/controllers/rails_sync_admin/base_controller.rb
39
+ - app/controllers/rails_sync_admin/sync_audits_controller.rb
40
+ - app/jobs/audit_apply_job.rb
41
+ - app/models/mysql/mysql_column.rb
42
+ - app/models/mysql/mysql_engine.rb
43
+ - app/models/mysql/mysql_index.rb
44
+ - app/models/mysql/mysql_server.rb
45
+ - app/models/mysql/mysql_table.rb
46
+ - app/models/sync_audit.rb
47
+ - app/views/rails_sync_admin/sync_audits/_filter.html.erb
48
+ - app/views/rails_sync_admin/sync_audits/_form.html.erb
49
+ - app/views/rails_sync_admin/sync_audits/_search_form.html.erb
50
+ - app/views/rails_sync_admin/sync_audits/edit.html.erb
51
+ - app/views/rails_sync_admin/sync_audits/index.html.erb
52
+ - app/views/rails_sync_admin/sync_audits/new.html.erb
53
+ - app/views/rails_sync_admin/sync_audits/show.html.erb
54
+ - app/views/rails_sync_admin/sync_audits/synchros.html.erb
55
+ - config/locales/en.yml
56
+ - config/locales/zh.yml
57
+ - config/routes.rb
58
+ - db/migrate/20180319031059_create_sync_audits.rb
59
+ - lib/rails_sync.rb
60
+ - lib/rails_sync/active_record.rb
61
+ - lib/rails_sync/adapter.rb
62
+ - lib/rails_sync/analyzer.rb
63
+ - lib/rails_sync/config.rb
64
+ - lib/rails_sync/engine.rb
65
+ - lib/rails_sync/table.rb
66
+ - lib/rails_sync/version.rb
67
+ - lib/tasks/the_sync_tasks.rake
68
+ homepage: https://github.com/yougexiangfa/rails_sync
69
+ licenses:
70
+ - LGPL-3.0
71
+ metadata: {}
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubyforge_project:
88
+ rubygems_version: 2.7.7
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Summary of RailsSync.
92
+ test_files: []