table_sync 0.0.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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