draisine 0.7.10
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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/Guardfile +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +134 -0
- data/Rakefile +6 -0
- data/app/controllers/draisine/soap_controller.rb +49 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/config/routes.rb +4 -0
- data/draisine.gemspec +32 -0
- data/lib/draisine/active_record.rb +191 -0
- data/lib/draisine/auditor/result.rb +48 -0
- data/lib/draisine/auditor.rb +130 -0
- data/lib/draisine/concerns/array_setter.rb +23 -0
- data/lib/draisine/concerns/attributes_mapping.rb +46 -0
- data/lib/draisine/concerns/import.rb +36 -0
- data/lib/draisine/conflict_detector.rb +38 -0
- data/lib/draisine/conflict_resolver.rb +97 -0
- data/lib/draisine/engine.rb +6 -0
- data/lib/draisine/importer.rb +111 -0
- data/lib/draisine/ip_checker.rb +15 -0
- data/lib/draisine/jobs/inbound_delete_job.rb +8 -0
- data/lib/draisine/jobs/inbound_update_job.rb +8 -0
- data/lib/draisine/jobs/job_base.rb +39 -0
- data/lib/draisine/jobs/outbound_create_job.rb +7 -0
- data/lib/draisine/jobs/outbound_delete_job.rb +7 -0
- data/lib/draisine/jobs/outbound_update_job.rb +7 -0
- data/lib/draisine/jobs/soap_delete_job.rb +7 -0
- data/lib/draisine/jobs/soap_update_job.rb +7 -0
- data/lib/draisine/partitioner.rb +73 -0
- data/lib/draisine/poller.rb +101 -0
- data/lib/draisine/query_mechanisms/base.rb +15 -0
- data/lib/draisine/query_mechanisms/default.rb +13 -0
- data/lib/draisine/query_mechanisms/last_modified_date.rb +18 -0
- data/lib/draisine/query_mechanisms/system_modstamp.rb +18 -0
- data/lib/draisine/query_mechanisms.rb +18 -0
- data/lib/draisine/registry.rb +22 -0
- data/lib/draisine/setup.rb +97 -0
- data/lib/draisine/soap_handler.rb +79 -0
- data/lib/draisine/syncer.rb +52 -0
- data/lib/draisine/type_mapper.rb +105 -0
- data/lib/draisine/util/caching_client.rb +73 -0
- data/lib/draisine/util/hash_diff.rb +39 -0
- data/lib/draisine/util/parse_time.rb +14 -0
- data/lib/draisine/util/salesforce_comparisons.rb +53 -0
- data/lib/draisine/version.rb +3 -0
- data/lib/draisine.rb +48 -0
- data/lib/ext/databasedotcom.rb +98 -0
- data/lib/generators/draisine/delta_migration_generator.rb +77 -0
- data/lib/generators/draisine/integration_generator.rb +53 -0
- data/lib/generators/draisine/templates/delta_migration.rb +24 -0
- data/lib/generators/draisine/templates/migration.rb +21 -0
- data/lib/generators/draisine/templates/model.rb +11 -0
- data/salesforce/sample_delete_trigger.apex +7 -0
- data/salesforce/sample_test_class_for_delete_trigger.apex +15 -0
- metadata +242 -0
@@ -0,0 +1,101 @@
|
|
1
|
+
module Draisine
|
2
|
+
class Poller
|
3
|
+
Result = Struct.new(:created_count, :updated_count, :deleted_count)
|
4
|
+
|
5
|
+
class <<self
|
6
|
+
def run(model_class:, mechanism: :default, start_date:, end_date: Time.current, **run_args)
|
7
|
+
partitions = partition(
|
8
|
+
model_class: model_class,
|
9
|
+
mechanism: mechanism,
|
10
|
+
start_date: start_date,
|
11
|
+
end_date: end_date,
|
12
|
+
partition_size: 10**12)
|
13
|
+
run_partition(partitions.first, **run_args)
|
14
|
+
end
|
15
|
+
alias_method :poll, :run
|
16
|
+
|
17
|
+
def partition(model_class:, mechanism: :default, start_date:, end_date: Time.current, partition_size: 100)
|
18
|
+
Partitioner.partition(
|
19
|
+
model_class: model_class,
|
20
|
+
mechanism: mechanism,
|
21
|
+
start_date: start_date,
|
22
|
+
end_date: end_date,
|
23
|
+
partition_size: partition_size)
|
24
|
+
end
|
25
|
+
|
26
|
+
def run_partition(partition, **run_args)
|
27
|
+
new(partition).run(**run_args)
|
28
|
+
end
|
29
|
+
alias_method :poll_partition, :run_partition
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
attr_reader :partition, :model_class, :start_date, :end_date
|
34
|
+
def initialize(partition)
|
35
|
+
@partition = partition
|
36
|
+
@model_class = partition.model_class
|
37
|
+
@start_date = partition.start_date
|
38
|
+
@end_date = partition.end_date
|
39
|
+
end
|
40
|
+
|
41
|
+
def run(import_created: true, import_updated: false, import_deleted: true)
|
42
|
+
created_count = updated_count = deleted_count = 0
|
43
|
+
if import_created || import_updated
|
44
|
+
created_count, updated_count = import_changes(import_created, import_updated)
|
45
|
+
end
|
46
|
+
|
47
|
+
deleted_count = import_deletes if import_deleted
|
48
|
+
|
49
|
+
Result.new(
|
50
|
+
created_count,
|
51
|
+
updated_count,
|
52
|
+
deleted_count)
|
53
|
+
end
|
54
|
+
alias_method :poll, :run
|
55
|
+
|
56
|
+
protected
|
57
|
+
|
58
|
+
def import_changes(import_created, import_updated)
|
59
|
+
updated_ids = partition.updated_ids
|
60
|
+
return [0, 0] unless updated_ids.present?
|
61
|
+
|
62
|
+
created_count = updated_count = 0
|
63
|
+
changed_objects = client.fetch_multiple(salesforce_object_name, updated_ids)
|
64
|
+
|
65
|
+
existing_models = model_class
|
66
|
+
.where(salesforce_id: updated_ids)
|
67
|
+
.each_with_object({}) { |model, rs| rs[model.salesforce_id] = model }
|
68
|
+
|
69
|
+
changed_objects.each do |object|
|
70
|
+
id = object.attributes.fetch('Id')
|
71
|
+
model = existing_models[id]
|
72
|
+
is_new = !model
|
73
|
+
attrs = object.attributes
|
74
|
+
if is_new && import_created
|
75
|
+
model_class.import_or_update_with_attrs(id, attrs)
|
76
|
+
created_count += 1
|
77
|
+
elsif !is_new && import_updated
|
78
|
+
if model.salesforce_update_without_sync(attrs, true)
|
79
|
+
updated_count += 1
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
[created_count, updated_count]
|
85
|
+
end
|
86
|
+
|
87
|
+
def import_deletes
|
88
|
+
deleted_ids = partition.deleted_ids
|
89
|
+
return 0 unless deleted_ids.present?
|
90
|
+
model_class.where(salesforce_id: deleted_ids).delete_all
|
91
|
+
end
|
92
|
+
|
93
|
+
def client
|
94
|
+
Draisine.salesforce_client
|
95
|
+
end
|
96
|
+
|
97
|
+
def salesforce_object_name
|
98
|
+
model_class.salesforce_object_name
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Draisine
|
2
|
+
module QueryMechanisms
|
3
|
+
class Base
|
4
|
+
attr_reader :model_class, :client
|
5
|
+
def initialize(model_class, client = Draisine.salesforce_client)
|
6
|
+
@model_class = model_class
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
def salesforce_object_name
|
11
|
+
model_class.salesforce_object_name
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Draisine
|
2
|
+
module QueryMechanisms
|
3
|
+
class Default < Base
|
4
|
+
def get_updated_ids(start_date, end_date)
|
5
|
+
client.get_updated_ids(salesforce_object_name, start_date, end_date)
|
6
|
+
end
|
7
|
+
|
8
|
+
def get_deleted_ids(start_date, end_date)
|
9
|
+
client.get_deleted_ids(salesforce_object_name, start_date, end_date)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Draisine
|
2
|
+
module QueryMechanisms
|
3
|
+
class LastModifiedDate < Base
|
4
|
+
def get_updated_ids(start_date, end_date)
|
5
|
+
response = client.query <<-EOQ
|
6
|
+
SELECT Id FROM #{salesforce_object_name}
|
7
|
+
WHERE LastModifiedDate >= #{start_date.iso8601}
|
8
|
+
AND LastModifiedDate <= #{end_date.iso8601}
|
9
|
+
EOQ
|
10
|
+
response.map(&:Id)
|
11
|
+
end
|
12
|
+
|
13
|
+
def get_deleted_ids(start_date, end_date)
|
14
|
+
[]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Draisine
|
2
|
+
module QueryMechanisms
|
3
|
+
class SystemModstamp < Base
|
4
|
+
def get_updated_ids(start_date, end_date)
|
5
|
+
response = client.query <<-EOQ
|
6
|
+
SELECT Id FROM #{salesforce_object_name}
|
7
|
+
WHERE SystemModstamp >= #{start_date.iso8601}
|
8
|
+
AND SystemModstamp <= #{end_date.iso8601}
|
9
|
+
EOQ
|
10
|
+
response.map(&:Id)
|
11
|
+
end
|
12
|
+
|
13
|
+
def get_deleted_ids(start_date, end_date)
|
14
|
+
[]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Draisine
|
2
|
+
module QueryMechanisms
|
3
|
+
require "draisine/query_mechanisms/base"
|
4
|
+
require "draisine/query_mechanisms/default"
|
5
|
+
require "draisine/query_mechanisms/system_modstamp"
|
6
|
+
require "draisine/query_mechanisms/last_modified_date"
|
7
|
+
|
8
|
+
MAP = {
|
9
|
+
default: Default,
|
10
|
+
system_modstamp: SystemModstamp,
|
11
|
+
last_modified_date: LastModifiedDate
|
12
|
+
}
|
13
|
+
|
14
|
+
def self.fetch(name)
|
15
|
+
MAP.fetch(name)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Draisine
|
2
|
+
class Registry
|
3
|
+
attr_reader :models
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@models = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def find(name)
|
10
|
+
models.fetch(name)
|
11
|
+
end
|
12
|
+
|
13
|
+
def register(model, name)
|
14
|
+
models[name] = model
|
15
|
+
models[model.name] ||= model
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.registry
|
20
|
+
@registry ||= Registry.new
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require "logger"
|
2
|
+
|
3
|
+
module Draisine
|
4
|
+
def self.salesforce_client=(client)
|
5
|
+
@salesforce_client = client
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.salesforce_client
|
9
|
+
unless @salesforce_client
|
10
|
+
fail <<-EOM
|
11
|
+
DatabaseDotcom client was not properly set up. You can set it up as follows:
|
12
|
+
sf_client = Databasedotcom::Client.new("config/databasedotcom.yml")
|
13
|
+
sf_client.authenticate :username => <username>, :password => <password>
|
14
|
+
Draisine.salesforce_client = sf_client
|
15
|
+
EOM
|
16
|
+
end
|
17
|
+
@salesforce_client
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.organization_id
|
21
|
+
unless @organization_id
|
22
|
+
fail <<-EOM
|
23
|
+
Draisine.organization_id was not properly set up.
|
24
|
+
You can use Draisine.organization_id= method to set it.
|
25
|
+
See https://cloudjedi.wordpress.com/no-fuss-salesforce-id-converter/ if
|
26
|
+
you need to convert your 15-char id into 18-char.
|
27
|
+
EOM
|
28
|
+
end
|
29
|
+
@organization_id
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.organization_id=(id)
|
33
|
+
unless id.kind_of?(String) && id.length == 18
|
34
|
+
fail ArgumentError, "You should set organization id to an 18 character string"
|
35
|
+
end
|
36
|
+
@organization_id = id
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.job_error_handler
|
40
|
+
@job_error_handler ||= proc {|error, job_instance, args| raise error }
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.job_error_handler=(handler)
|
44
|
+
@job_error_handler = handler
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.sync_callback
|
48
|
+
@sync_callback ||= proc {|type, salesforce_id, options| }
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.sync_callback=(callback)
|
52
|
+
@sync_callback = callback
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.job_retry_attempts
|
56
|
+
@job_retry_attempts ||= 0
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.sync_soap_operations?
|
60
|
+
@sync_soap_operations = true if @sync_soap_operations.nil?
|
61
|
+
@sync_soap_operations
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.sync_soap_operations=(value)
|
65
|
+
@sync_soap_operations = value
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.job_retry_attempts=(count)
|
69
|
+
@job_retry_attempts = count
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.invalid_organization_handler
|
73
|
+
@invalid_organization_handler ||= proc {|message| fail Draisine::SoapHandler::InvalidOrganizationError, "invalid organization id in the inbound message from salesforce" }
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.invalid_organization_handler=(handler)
|
77
|
+
@invalid_organization_handler = handler
|
78
|
+
end
|
79
|
+
|
80
|
+
# https://help.salesforce.com/apex/HTViewSolution?language=en_US&id=000003652
|
81
|
+
def self.allowed_ip_ranges
|
82
|
+
@allowed_ip_ranges ||= [
|
83
|
+
'96.43.144.0/20',
|
84
|
+
'136.146.210.8/15',
|
85
|
+
'204.14.232.0/21',
|
86
|
+
'85.222.128.0/19',
|
87
|
+
'185.79.140.0/22',
|
88
|
+
'182.50.76.0/22',
|
89
|
+
'202.129.242.0/23',
|
90
|
+
'127.0.0.1'
|
91
|
+
]
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.allowed_ip_ranges=(ranges)
|
95
|
+
@allowed_ip_ranges = ranges
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'active_support/core_ext/hash/conversions'
|
2
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
3
|
+
|
4
|
+
module Draisine
|
5
|
+
class SoapHandler
|
6
|
+
InvalidOrganizationError = Class.new(StandardError)
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
end
|
10
|
+
|
11
|
+
def update(message_xml)
|
12
|
+
message = parse(message_xml)
|
13
|
+
|
14
|
+
assert_valid_message!(message)
|
15
|
+
extract_sobjects(message).each do |sobject|
|
16
|
+
type = sobject.fetch('xsi:type').sub('sf:', '')
|
17
|
+
klass = Draisine.registry.find(type)
|
18
|
+
klass.salesforce_on_inbound_update(sobject)
|
19
|
+
end
|
20
|
+
rescue InvalidOrganizationError => e
|
21
|
+
Draisine.invalid_organization_handler.call(message)
|
22
|
+
end
|
23
|
+
|
24
|
+
def delete(message_xml)
|
25
|
+
message = parse(message_xml)
|
26
|
+
|
27
|
+
assert_valid_message!(message)
|
28
|
+
extract_sobjects(message).each do |sobject|
|
29
|
+
type = sobject.fetch('Object_Type__c')
|
30
|
+
id = sobject.fetch('Object_Id__c')
|
31
|
+
klass = Draisine.registry.find(type)
|
32
|
+
klass.salesforce_on_inbound_delete(id)
|
33
|
+
end
|
34
|
+
rescue InvalidOrganizationError => e
|
35
|
+
Draisine.invalid_organization_handler.call(message)
|
36
|
+
end
|
37
|
+
|
38
|
+
protected
|
39
|
+
|
40
|
+
def parse(message_xml)
|
41
|
+
case message_xml
|
42
|
+
when Hash
|
43
|
+
message_xml
|
44
|
+
when String
|
45
|
+
Hash.from_xml(message_xml)
|
46
|
+
else
|
47
|
+
raise ArgumentError
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def extract_sobjects(message)
|
52
|
+
Array.wrap(message['Envelope']['Body']['notifications']['Notification']).map do |sobject|
|
53
|
+
sobject.fetch('sObject')
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def assert_valid_organization_id!(message)
|
58
|
+
unless diggable_to?(message, ['Envelope', 'Body', 'notifications', 'OrganizationId']) &&
|
59
|
+
message['Envelope']['Body']['notifications']['OrganizationId'] == Draisine.organization_id
|
60
|
+
fail InvalidOrganizationError, "a message from invalid organization id received"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def assert_valid_message!(message)
|
65
|
+
unless diggable_to?(message, ['Envelope', 'Body', 'notifications', 'Notification'])
|
66
|
+
fail ArgumentError, "malformed xml inbound message from salesforce"
|
67
|
+
end
|
68
|
+
assert_valid_organization_id!(message)
|
69
|
+
end
|
70
|
+
|
71
|
+
def diggable_to?(hash, path)
|
72
|
+
path.each do |key|
|
73
|
+
return false unless hash.respond_to?(:key?) && hash.key?(key)
|
74
|
+
hash = hash[key]
|
75
|
+
end
|
76
|
+
true
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Draisine
|
2
|
+
# Wrapper around salesforce client implementation
|
3
|
+
# Might have pluggable adapters in the future.
|
4
|
+
class Syncer
|
5
|
+
attr_reader :salesforce_object_name
|
6
|
+
|
7
|
+
def initialize(salesforce_object_name, client = nil)
|
8
|
+
@salesforce_object_name ||= salesforce_object_name
|
9
|
+
@client = client
|
10
|
+
end
|
11
|
+
|
12
|
+
def get(id, options = {})
|
13
|
+
raise ArgumentError unless id.present?
|
14
|
+
response = client.http_get(build_sobject_url(id), options)
|
15
|
+
JSON.parse(response.body)
|
16
|
+
end
|
17
|
+
|
18
|
+
def create(attrs)
|
19
|
+
response = client.http_post(build_sobject_url(nil), attrs.to_json)
|
20
|
+
JSON.parse(response.body)
|
21
|
+
end
|
22
|
+
|
23
|
+
def update(id, attrs)
|
24
|
+
raise ArgumentError unless id.present?
|
25
|
+
return unless attrs.present?
|
26
|
+
client.http_patch(build_sobject_url(id), attrs.to_json)
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete(id)
|
30
|
+
raise ArgumentError unless id.present?
|
31
|
+
client.http_delete(build_sobject_url(id))
|
32
|
+
end
|
33
|
+
|
34
|
+
def get_system_modstamp(id)
|
35
|
+
raise ArgumentError unless id.present?
|
36
|
+
time = get(id, fields: "SystemModstamp")['SystemModstamp']
|
37
|
+
time && Time.parse(time)
|
38
|
+
end
|
39
|
+
|
40
|
+
protected
|
41
|
+
|
42
|
+
def client
|
43
|
+
@client || Draisine.salesforce_client
|
44
|
+
end
|
45
|
+
|
46
|
+
def build_sobject_url(id)
|
47
|
+
url = "/services/data/v#{client.version}/sobjects/#{salesforce_object_name}"
|
48
|
+
url << "/#{id}" if url
|
49
|
+
url
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
module Draisine
|
2
|
+
class TypeMapper
|
3
|
+
Type = Struct.new(:ar_type, :ar_options, :serialized, :array)
|
4
|
+
|
5
|
+
ActiveRecordColumnDef = Struct.new(:column_name, :column_type, :options) do
|
6
|
+
def self.from_ar_column(ar_col)
|
7
|
+
new(ar_col.name, ar_col.type, { limit: ar_col.limit }.compact)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
MAX_ALLOWED_STRING_TYPE_LENGTH = 40
|
12
|
+
|
13
|
+
def self.type(ar_type, ar_options: {}, serialized: false, array: false)
|
14
|
+
Type.new(ar_type, ar_options, serialized, array)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.determine_type_for_float(name, sf_schema)
|
18
|
+
if (precision = sf_schema[:precision]) && (scale = sf_schema[:scale]) && scale == 0
|
19
|
+
type(:integer, ar_options: { limit: 8 })
|
20
|
+
else
|
21
|
+
type(:float, ar_options: { limit: 53 })
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.determine_type_for_string(name, sf_schema)
|
26
|
+
if (length = sf_schema[:length]) && length > 0 && length <= MAX_ALLOWED_STRING_TYPE_LENGTH
|
27
|
+
type(:string, ar_options: { limit: length })
|
28
|
+
else
|
29
|
+
type(:text)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Apparently, mysql has a hard limit of 64k per row.
|
34
|
+
# That's why we're using text types where we could also use strings.
|
35
|
+
TYPES_MAP = {
|
36
|
+
"boolean" => type(:boolean),
|
37
|
+
"string" => method(:determine_type_for_string),
|
38
|
+
"reference" => type(:string, ar_options: { limit: 20 }),
|
39
|
+
"picklist" => type(:binary, serialized: true),
|
40
|
+
"textarea" => method(:determine_type_for_string),
|
41
|
+
"phone" => method(:determine_type_for_string),
|
42
|
+
"email" => method(:determine_type_for_string),
|
43
|
+
"url" => method(:determine_type_for_string),
|
44
|
+
"int" => type(:integer),
|
45
|
+
"date" => type(:date),
|
46
|
+
"time" => type(:time),
|
47
|
+
"multipicklist" => type(:binary, serialized: true, array: true),
|
48
|
+
"double" => method(:determine_type_for_float),
|
49
|
+
"datetime" => type(:datetime),
|
50
|
+
"anyType" => type(:binary, serialized: true),
|
51
|
+
"combobox" => type(:text),
|
52
|
+
"currency" => type(:decimal, ar_options: { precision: 18, scale: 6 }),
|
53
|
+
"percent" => type(:decimal, ar_options: { precision: 18, scale: 6 })
|
54
|
+
# Leave this one for now
|
55
|
+
# "encrypted_string" => :string,
|
56
|
+
}
|
57
|
+
|
58
|
+
EXCLUDED_COLUMNS = ["Id"]
|
59
|
+
|
60
|
+
attr_reader :sf_type_map, :type_map
|
61
|
+
def initialize(sf_type_map)
|
62
|
+
@sf_type_map = sf_type_map
|
63
|
+
@type_map = sf_type_map.reject {|name, schema| ignored_column?(name, schema) }.
|
64
|
+
select {|name, schema| type_for(name, schema) }.
|
65
|
+
map {|name, schema| [name, type_for(name, schema)] }.
|
66
|
+
to_h
|
67
|
+
end
|
68
|
+
|
69
|
+
def active_record_column_defs
|
70
|
+
type_map.map {|name, type| active_record_column_def(name, type) }
|
71
|
+
end
|
72
|
+
|
73
|
+
def columns
|
74
|
+
@columns ||= type_map.keys
|
75
|
+
end
|
76
|
+
|
77
|
+
def updateable_columns
|
78
|
+
@updateable_columns ||= sf_type_map.select {|_, type| type[:updateable?] }.keys
|
79
|
+
end
|
80
|
+
|
81
|
+
def serialized_columns
|
82
|
+
@serialized_columns ||= type_map.select {|_, type| type.serialized }.keys
|
83
|
+
end
|
84
|
+
|
85
|
+
def array_columns
|
86
|
+
@array_columns ||= type_map.select {|_, type| type.array }.keys
|
87
|
+
end
|
88
|
+
|
89
|
+
protected
|
90
|
+
|
91
|
+
def type_for(sf_column_name, sf_column_schema)
|
92
|
+
sf_type = sf_column_schema.fetch(:type)
|
93
|
+
type = TYPES_MAP.fetch(sf_type) { warn "Unknown column type #{sf_type} for column #{sf_column_name}, ignoring it" }
|
94
|
+
type.respond_to?(:call) ? type.call(sf_column_name, sf_column_schema) : type
|
95
|
+
end
|
96
|
+
|
97
|
+
def ignored_column?(sf_column_name, sf_column_schema)
|
98
|
+
EXCLUDED_COLUMNS.include?(sf_column_name)
|
99
|
+
end
|
100
|
+
|
101
|
+
def active_record_column_def(column_name, type)
|
102
|
+
ActiveRecordColumnDef.new(column_name, type.ar_type, type.ar_options)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Draisine
|
2
|
+
class CachingClient
|
3
|
+
class Cache
|
4
|
+
attr_reader :cache
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@cache = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](key)
|
11
|
+
cache[key]
|
12
|
+
end
|
13
|
+
|
14
|
+
def fetch(key, &block)
|
15
|
+
cache.fetch(key) { cache[key] = yield }
|
16
|
+
end
|
17
|
+
|
18
|
+
def []=(key, value)
|
19
|
+
cache[key] = value
|
20
|
+
end
|
21
|
+
|
22
|
+
def add(record)
|
23
|
+
self[record.attributes.fetch('Id')] = record
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_multiple(records)
|
27
|
+
records.each {|record| add(record) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def has_ids?(ids)
|
31
|
+
(ids - cache.keys).empty?
|
32
|
+
end
|
33
|
+
|
34
|
+
def fetch_multiple(ids, &block)
|
35
|
+
if has_ids?(ids)
|
36
|
+
cache.values_at(*ids)
|
37
|
+
else
|
38
|
+
yield.tap do |records|
|
39
|
+
add_multiple(records)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
attr_reader :cache_map, :client
|
46
|
+
|
47
|
+
def initialize(client = Draisine.salesforce_client)
|
48
|
+
@cache_map = Hash.new {|h,k| h[k] = Cache.new }
|
49
|
+
@client = client
|
50
|
+
end
|
51
|
+
|
52
|
+
def find(salesforce_object_name, id)
|
53
|
+
cache_map[salesforce_object_name].fetch(id) do
|
54
|
+
client.find(salesforce_object_name, id)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def fetch_multiple(salesforce_object_name, ids)
|
59
|
+
cache_map[salesforce_object_name].fetch_multiple(ids) do
|
60
|
+
client.fetch_multiple(salesforce_object_name, ids)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
alias_method :prefetch, :fetch_multiple
|
64
|
+
|
65
|
+
def method_missing(method, *args, &block)
|
66
|
+
if client.respond_to?(method)
|
67
|
+
client.__send__(method, *args, &block)
|
68
|
+
else
|
69
|
+
super
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Draisine
|
2
|
+
HashDiff = Struct.new(:added, :removed, :changed, :unchanged) do
|
3
|
+
def self.diff(hash1, hash2, equality = -> (a, b) { a == b })
|
4
|
+
unless hash1.respond_to?(:key?) && hash2.respond_to?(:key?)
|
5
|
+
fail ArgumentError, "both arguments should be hashes"
|
6
|
+
end
|
7
|
+
|
8
|
+
added = []
|
9
|
+
removed = []
|
10
|
+
changed = []
|
11
|
+
unchanged = []
|
12
|
+
|
13
|
+
(hash1.keys | hash2.keys).each do |key|
|
14
|
+
if hash1.key?(key) && hash2.key?(key)
|
15
|
+
if equality.call(hash1[key], hash2[key])
|
16
|
+
unchanged << key
|
17
|
+
else
|
18
|
+
changed << key
|
19
|
+
end
|
20
|
+
elsif hash1.key?(key)
|
21
|
+
removed << key
|
22
|
+
else
|
23
|
+
added << key
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
new(added, removed, changed, unchanged)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.sf_diff(hash1, hash2)
|
31
|
+
diff(hash1, hash2, SalesforceComparisons.method(:salesforce_equals?))
|
32
|
+
end
|
33
|
+
|
34
|
+
def diff_keys
|
35
|
+
changed | added | removed
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|