table_sync 0.0.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync
4
+ module DSL
5
+ def inherited(klass)
6
+ klass.instance_variable_set(:@configs, configs.deep_dup)
7
+ super
8
+ end
9
+
10
+ def configs
11
+ @configs ||= Hash.new { |hash, key| hash[key] = [] }
12
+ end
13
+
14
+ def receive(source, to_table:, events: nil, &block)
15
+ config = ::TableSync::Config.new(
16
+ model: TableSync.orm.model.new(to_table),
17
+ events: events,
18
+ )
19
+
20
+ config.instance_exec(&block) if block
21
+
22
+ configs[source.to_s] << config
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync
4
+ Error = Class.new(StandardError)
5
+
6
+ class UpsertError < Error
7
+ def initialize(data:, target_keys:, version_key:, first_sync_time_key:, default_values:)
8
+ super <<~MSG
9
+ Upsert has changed more than 1 row;
10
+ data: #{data.inspect}
11
+ target_keys: #{target_keys.inspect}
12
+ version_key: #{version_key.inspect}
13
+ first_sync_time_key: #{first_sync_time_key.inspect}
14
+ default_values: #{default_values.inspect}
15
+ MSG
16
+ end
17
+ end
18
+
19
+ class DestroyError < Error
20
+ def initialize(data)
21
+ super("Destroy has changed more than 1 row; data: #{data.inspect}")
22
+ end
23
+ end
24
+
25
+ class UndefinedConfig < Error
26
+ def initialize(model)
27
+ super("Config not defined for model; model: #{model.inspect}")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync
4
+ module EventActions
5
+ def update(data) # rubocop:disable Metrics/MethodLength
6
+ model.transaction do
7
+ args = {
8
+ data: data,
9
+ target_keys: target_keys,
10
+ version_key: version_key,
11
+ first_sync_time_key: first_sync_time_key,
12
+ default_values: default_values,
13
+ }
14
+
15
+ @config.callback_registry.get_callbacks(kind: :before_commit, event: :update).each do |cb|
16
+ cb[data.values.flatten]
17
+ end
18
+
19
+ results = data.reduce([]) do |upserts, (part_model, part_data)|
20
+ upserts + part_model.upsert(**args, data: part_data)
21
+ end
22
+
23
+ return if results.empty?
24
+ raise TableSync::UpsertError.new(**args) unless correct_keys?(results)
25
+
26
+ @config.model.after_commit do
27
+ @config.callback_registry.get_callbacks(kind: :after_commit, event: :update).each do |cb|
28
+ cb[results]
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ def destroy(data)
35
+ attributes = data.first || {}
36
+ target_attributes = attributes.select { |key| target_keys.include?(key) }
37
+
38
+ model.transaction do
39
+ @config.callback_registry.get_callbacks(kind: :before_commit, event: :destroy).each do |cb|
40
+ cb[attributes]
41
+ end
42
+
43
+ results = model.destroy(target_attributes)
44
+
45
+ return if results.empty?
46
+ raise TableSync::DestroyError.new(target_attributes) if results.size != 1
47
+
48
+ @config.model.after_commit do
49
+ @config.callback_registry.get_callbacks(kind: :after_commit, event: :destroy).each do |cb|
50
+ cb[results]
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def correct_keys?(query_results)
57
+ query_results.uniq { |d| d.slice(*target_keys) }.size == query_results.size
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync::Model
4
+ class ActiveRecord
5
+ class AfterCommitWrap
6
+ def initialize(&block)
7
+ @callback = block
8
+ end
9
+
10
+ def committed!(*)
11
+ @callback.call
12
+ end
13
+
14
+ def before_committed!(*); end
15
+
16
+ def rolledback!(*); end
17
+ end
18
+
19
+ def initialize(table_name)
20
+ @raw_model = Class.new(::ActiveRecord::Base) do
21
+ self.table_name = table_name
22
+ self.inheritance_column = nil
23
+ end
24
+ end
25
+
26
+ def columns
27
+ raw_model.column_names.map(&:to_sym)
28
+ end
29
+
30
+ def primary_keys
31
+ db.execute(<<~SQL).column_values(0).map(&:to_sym)
32
+ SELECT kcu.column_name
33
+ FROM INFORMATION_SCHEMA.TABLES t
34
+ LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
35
+ ON tc.table_catalog = t.table_catalog
36
+ AND tc.table_schema = t.table_schema
37
+ AND tc.table_name = t.table_name
38
+ AND tc.constraint_type = 'PRIMARY KEY'
39
+ LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
40
+ ON kcu.table_catalog = tc.table_catalog
41
+ AND kcu.table_schema = tc.table_schema
42
+ AND kcu.table_name = tc.table_name
43
+ AND kcu.constraint_name = tc.constraint_name
44
+ WHERE
45
+ t.table_schema NOT IN ('pg_catalog', 'information_schema')
46
+ AND t.table_schema = '#{table_info[:schema]}'
47
+ AND t.table_name = '#{table_info[:name]}'
48
+ ORDER BY
49
+ kcu.ordinal_position
50
+ SQL
51
+ end
52
+
53
+ def upsert(data:, target_keys:, version_key:, first_sync_time_key:, default_values:)
54
+ data = Array.wrap(data)
55
+
56
+ transaction do
57
+ data.map do |datum|
58
+ conditions = datum.select { |k| target_keys.include?(k) }
59
+
60
+ row = raw_model.lock("FOR NO KEY UPDATE").find_by(conditions)
61
+ if row
62
+ next if datum[version_key] <= row[version_key]
63
+
64
+ row.update!(datum)
65
+ else
66
+ create_data = datum.merge(default_values)
67
+ create_data[first_sync_time_key] = Time.current if first_sync_time_key
68
+ row = raw_model.create!(create_data)
69
+ end
70
+
71
+ row_to_hash(row)
72
+ end.compact
73
+ end
74
+ end
75
+
76
+ def destroy(data)
77
+ transaction do
78
+ row = raw_model.lock("FOR UPDATE").find_by(data)&.destroy!
79
+ [row_to_hash(row)]
80
+ end
81
+ end
82
+
83
+ def transaction(&block)
84
+ ::ActiveRecord::Base.transaction(&block)
85
+ end
86
+
87
+ def after_commit(&block)
88
+ db.add_transaction_record(AfterCommitWrap.new(&block))
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :raw_model
94
+
95
+ def table_info
96
+ keys = raw_model.table_name.split(".")
97
+ name = keys[-1]
98
+ schema = keys[-2] || "public"
99
+ { schema: schema, name: name }
100
+ end
101
+
102
+ def db
103
+ @raw_model.connection
104
+ end
105
+
106
+ def row_to_hash(row)
107
+ row.attributes.each_with_object({}) { |(k, v), o| o[k.to_sym] = v }
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync::Model
4
+ class Sequel
5
+ def initialize(table_name)
6
+ @raw_model = Class.new(::Sequel::Model(table_name)).tap(&:unrestrict_primary_key)
7
+ end
8
+
9
+ def columns
10
+ dataset.columns
11
+ end
12
+
13
+ def primary_keys
14
+ [raw_model.primary_key].flatten
15
+ end
16
+
17
+ def upsert(data:, target_keys:, version_key:, first_sync_time_key:, default_values:)
18
+ data = Array.wrap(data)
19
+ qualified_version = ::Sequel.qualify(table_name, version_key)
20
+ version_condition = ::Sequel.function(:coalesce, qualified_version, 0) <
21
+ ::Sequel.qualify(:excluded, version_key)
22
+
23
+ upd_spec = update_spec(data.first.keys - target_keys)
24
+ data.map! { |d| d.merge(default_values) }
25
+
26
+ insert_data = type_cast(data)
27
+ if first_sync_time_key
28
+ insert_data.each { |datum| datum[first_sync_time_key] = Time.current }
29
+ end
30
+
31
+ dataset.returning
32
+ .insert_conflict(
33
+ target: target_keys,
34
+ update: upd_spec,
35
+ update_where: version_condition,
36
+ )
37
+ .multi_insert(insert_data)
38
+ end
39
+
40
+ def destroy(data)
41
+ dataset.returning.where(data).delete
42
+ end
43
+
44
+ def transaction(&block)
45
+ db.transaction(&block)
46
+ end
47
+
48
+ def after_commit(&block)
49
+ db.after_commit(&block)
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :raw_model
55
+
56
+ def table_name
57
+ raw_model.table_name
58
+ end
59
+
60
+ def dataset
61
+ raw_model.dataset
62
+ end
63
+
64
+ def db
65
+ dataset.db
66
+ end
67
+
68
+ def type_cast(data)
69
+ data.map { |d| raw_model.new(d).values.keep_if { |k| d.key?(k) } }
70
+ end
71
+
72
+ def update_spec(keys)
73
+ keys.map { |key| [key, ::Sequel[:excluded][key]] }.to_h
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync::ORMAdapter
4
+ module ActiveRecord
5
+ module_function
6
+
7
+ def model
8
+ ::TableSync::Model::ActiveRecord
9
+ end
10
+
11
+ def find(dataset, conditions)
12
+ dataset.find_by(conditions)
13
+ end
14
+
15
+ def attributes(object)
16
+ object.attributes
17
+ end
18
+
19
+ def setup_sync(klass, **opts)
20
+ klass.instance_exec do
21
+ { create: :created, update: :updated, destroy: :destroyed }.each do |event, state|
22
+ after_commit(on: event, **opts) do
23
+ TableSync::Publisher.new(self.class.name, attributes, state: state).publish
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TableSync::ORMAdapter
4
+ module Sequel
5
+ module_function
6
+
7
+ def model
8
+ ::TableSync::Model::Sequel
9
+ end
10
+
11
+ def find(dataset, conditions)
12
+ dataset.find(conditions)
13
+ end
14
+
15
+ def attributes(object)
16
+ object.values
17
+ end
18
+
19
+ def setup_sync(klass, **opts)
20
+ if_predicate = to_predicate(opts.delete(:if), true)
21
+ unless_predicate = to_predicate(opts.delete(:unless), false)
22
+ raise "Only :if and :unless options are currently supported for Sequel hooks" if opts.any?
23
+
24
+ { create: :created, update: :updated }.each do |event, state|
25
+ klass.send(:define_method, :"after_#{event}") do
26
+ if instance_eval(&if_predicate) && !instance_eval(&unless_predicate)
27
+ db.after_commit do
28
+ TableSync::Publisher.new(self.class.name, values, state: state).publish
29
+ end
30
+ end
31
+ super()
32
+ end
33
+ end
34
+
35
+ klass.send(:define_method, :after_destroy) do
36
+ # publish anyway
37
+ db.after_commit do
38
+ TableSync::Publisher.new(self.class.name, values, state: :destroyed).publish
39
+ end
40
+ super()
41
+ end
42
+ end
43
+
44
+ def to_predicate(val, default)
45
+ return val.to_proc if val.respond_to?(:to_proc)
46
+
47
+ -> (*) { default }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TableSync::Publisher < TableSync::BasePublisher
4
+ DEBOUNCE_TIME = 1.minute
5
+
6
+ # 'original_attributes' are not published, they are used to resolve the routing key
7
+ def initialize(object_class, original_attributes, destroyed: nil, confirm: true, state: :updated)
8
+ @object_class = object_class.constantize
9
+ @original_attributes = filter_safe_for_serialization(original_attributes.deep_symbolize_keys)
10
+ @confirm = confirm
11
+
12
+ if destroyed.nil?
13
+ @state = validate_state(state)
14
+ else
15
+ # TODO Legacy job support, remove
16
+ @state = destroyed ? :destroyed : :updated
17
+ end
18
+ end
19
+
20
+ def publish
21
+ return enqueue_job if destroyed?
22
+
23
+ sync_time = Rails.cache.read(cache_key) || current_time - DEBOUNCE_TIME - 1.second
24
+ return if sync_time > current_time
25
+
26
+ next_sync_time = sync_time + DEBOUNCE_TIME
27
+ next_sync_time <= current_time ? enqueue_job : enqueue_job(next_sync_time)
28
+ end
29
+
30
+ def publish_now
31
+ # Update request and object does not exist -> skip publishing
32
+ return if !object && !destroyed?
33
+
34
+ Rabbit.publish(params)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :original_attributes
40
+ attr_reader :state
41
+
42
+ def attrs_for_callables
43
+ original_attributes
44
+ end
45
+
46
+ def attrs_for_routing_key
47
+ return object.attrs_for_routing_key if attrs_for_routing_key_defined?
48
+ attrs_for_callables
49
+ end
50
+
51
+ def attrs_for_metadata
52
+ return object.attrs_for_metadata if attrs_for_metadata_defined?
53
+ attrs_for_callables
54
+ end
55
+
56
+ def job_callable
57
+ TableSync.publishing_job_class_callable
58
+ end
59
+
60
+ def job_callable_error_message
61
+ "Can't publish, set TableSync.publishing_job_class_callable"
62
+ end
63
+
64
+ def enqueue_job(perform_at = current_time)
65
+ job = job_class.set(wait_until: perform_at)
66
+ job.perform_later(object_class.name, original_attributes, state: state.to_s, confirm: confirm?)
67
+ Rails.cache.write(cache_key, perform_at)
68
+ end
69
+
70
+ def routing_key
71
+ resolve_routing_key
72
+ end
73
+
74
+ def publishing_data
75
+ {
76
+ **super,
77
+ event: (destroyed? ? :destroy : :update),
78
+ metadata: { created: created? },
79
+ }
80
+ end
81
+
82
+ def attributes_for_sync
83
+ if destroyed?
84
+ if object_class.respond_to?(:table_sync_destroy_attributes)
85
+ object_class.table_sync_destroy_attributes(original_attributes)
86
+ else
87
+ needle
88
+ end
89
+ elsif attributes_for_sync_defined?
90
+ object.attributes_for_sync
91
+ else
92
+ TableSync.orm.attributes(object)
93
+ end
94
+ end
95
+
96
+ memoize def object
97
+ TableSync.orm.find(object_class, needle)
98
+ end
99
+
100
+ def needle
101
+ original_attributes.slice(*primary_keys)
102
+ end
103
+
104
+ def cache_key
105
+ "#{object_class}/#{needle}_table_sync_time".delete(" ")
106
+ end
107
+
108
+ def destroyed?
109
+ state == :destroyed
110
+ end
111
+
112
+ def created?
113
+ state == :created
114
+ end
115
+
116
+ def validate_state(state)
117
+ if %i[created updated destroyed].include?(state&.to_sym)
118
+ state.to_sym
119
+ else
120
+ raise "Unknown state: #{state.inspect}"
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TableSync::ReceivingHandler < Rabbit::EventHandler
4
+ extend TableSync::DSL
5
+
6
+ attribute :event
7
+ attribute :model
8
+ attribute :version
9
+
10
+ def call
11
+ raise TableSync::UndefinedConfig.new(model) if configs.blank?
12
+
13
+ configs.each do |config|
14
+ next unless config.allow_event?(event)
15
+
16
+ data = processed_data(config)
17
+ next if data.empty?
18
+
19
+ case event
20
+ when :update
21
+ config.model.transaction do
22
+ config.update(data)
23
+ end
24
+ when :destroy
25
+ config.destroy(data.values.first)
26
+ else
27
+ raise "Unknown event: #{event}"
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def data=(data)
35
+ @data = data[:attributes]
36
+ end
37
+
38
+ def event=(name)
39
+ super(name.to_sym)
40
+ end
41
+
42
+ def model=(name)
43
+ super(name.to_s)
44
+ end
45
+
46
+ def configs
47
+ @configs ||= self.class.configs[model]
48
+ &.map { |c| ::TableSync::ConfigDecorator.new(c, self) }
49
+ end
50
+
51
+ def processed_data(config)
52
+ parts = config.partitions&.transform_keys { |k| config.model.class.new(k) } ||
53
+ { config.model => Array.wrap(data) }
54
+
55
+ parts.transform_values! do |data_part|
56
+ data_part.map do |row|
57
+ original_row_for_data = row.dup
58
+ row = row.dup
59
+
60
+ config.mapping_overrides.each do |before, after|
61
+ row[after] = row.delete(before)
62
+ end
63
+
64
+ only = config.only
65
+ row, missed = row.partition { |key, _| key.in?(only) }.map(&:to_h)
66
+
67
+ row.deep_merge!(config.rest_key => missed) if config.rest_key
68
+ row[config.version_key] = version
69
+
70
+ row.merge!(config.additional_data(original_row_for_data))
71
+
72
+ row unless config.skip(original_row_for_data)
73
+ end.compact.presence
74
+ end.compact
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TableSync
4
- VERSION = "0.0.0"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/table_sync.rb CHANGED
@@ -1,5 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "memery"
4
+ require "rabbit_messaging"
5
+ require "rabbit/event_handler" # NOTE: from rabbit_messaging"
6
+ require "active_support/core_ext/object/blank"
7
+ require "active_support/core_ext/numeric/time"
8
+
3
9
  module TableSync
4
- require "table_sync/version"
10
+ require_relative "./table_sync/version"
11
+ require_relative "./table_sync/errors"
12
+ require_relative "./table_sync/event_actions"
13
+ require_relative "./table_sync/config"
14
+ require_relative "./table_sync/config/callback_registry"
15
+ require_relative "./table_sync/config_decorator"
16
+ require_relative "./table_sync/dsl"
17
+ require_relative "./table_sync/receiving_handler"
18
+ require_relative "./table_sync/base_publisher"
19
+ require_relative "./table_sync/publisher"
20
+ require_relative "./table_sync/batch_publisher"
21
+ require_relative "./table_sync/orm_adapter/active_record"
22
+ require_relative "./table_sync/orm_adapter/sequel"
23
+ require_relative "./table_sync/model/active_record"
24
+ require_relative "./table_sync/model/sequel"
25
+
26
+ class << self
27
+ include Memery
28
+
29
+ attr_accessor :publishing_job_class_callable
30
+ attr_accessor :batch_publishing_job_class_callable
31
+ attr_accessor :routing_key_callable
32
+ attr_accessor :exchange_name
33
+ attr_accessor :routing_metadata_callable
34
+
35
+ def sync(*args)
36
+ orm.setup_sync(*args)
37
+ end
38
+
39
+ def orm=(val)
40
+ clear_memery_cache!
41
+ @orm = val
42
+ end
43
+
44
+ memoize def orm
45
+ case @orm
46
+ when :active_record
47
+ ORMAdapter::ActiveRecord
48
+ when :sequel
49
+ ORMAdapter::Sequel
50
+ else
51
+ raise "ORM not supported: #{@orm.inspect}"
52
+ end
53
+ end
54
+ end
5
55
  end
data/table_sync.gemspec CHANGED
@@ -11,8 +11,10 @@ Gem::Specification.new do |spec|
11
11
  spec.version = TableSync::VERSION
12
12
  spec.authors = ["Umbrellio"]
13
13
  spec.email = ["oss@umbrellio.biz"]
14
- spec.summary = "Coming soon"
15
- spec.description = "Coming soon"
14
+ spec.summary = "DB Table synchronization between microservices " \
15
+ "based on Model's event system and RabbitMQ messaging"
16
+ spec.description = "DB Table synchronization between microservices " \
17
+ "based on Model's event system and RabbitMQ messaging"
16
18
  spec.homepage = "https://github.com/umbrellio/table_sync"
17
19
  spec.license = "MIT"
18
20
 
@@ -24,12 +26,23 @@ Gem::Specification.new do |spec|
24
26
  f.match(%r{^(test|spec|features)/})
25
27
  end
26
28
 
29
+ spec.add_runtime_dependency "memery"
30
+ spec.add_runtime_dependency "rabbit_messaging", "~> 0.3"
31
+ spec.add_runtime_dependency "rails"
32
+
27
33
  spec.add_development_dependency "coveralls", "~> 0.8"
28
34
  spec.add_development_dependency "rspec", "~> 3.8"
29
35
  spec.add_development_dependency "rubocop-config-umbrellio", "~> 0.70"
30
36
  spec.add_development_dependency "simplecov", "~> 0.16"
31
37
 
38
+ spec.add_development_dependency "activejob"
39
+ spec.add_development_dependency "activerecord"
40
+ spec.add_development_dependency "pg", "~> 0.18"
41
+ spec.add_development_dependency "sequel"
42
+ spec.add_development_dependency "timecop"
43
+
32
44
  spec.add_development_dependency "bundler"
45
+ spec.add_development_dependency "bundler-audit"
33
46
  spec.add_development_dependency "pry"
34
47
  spec.add_development_dependency "rake"
35
48
  end