salesforce_bulk_api_serial_or_parallel 0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b42acc44b480f4bc913f265ca0e5f1ee9d6eb27c
4
+ data.tar.gz: 2fae9dadedce4fea9d26be4828a27c0cbc662304
5
+ SHA512:
6
+ metadata.gz: de7e1e445ecacd7b832830175fa501146de6e210f1b653a3f579f129135993b12a592802716ab3d523a214031f99643928f202bbe35d73d7deca0a7cac3ef32f
7
+ data.tar.gz: 9545be081a46a0f17a1e34ff20c8cf30fcfe9277dded5b21f7cc460fd0431c55edde2f74e751a145a0bec57e73fec52ebd9e6a1e8e9110bcff6ea6f3469eee49
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ .ruby-gemset
5
+ .ruby-version
6
+ pkg/*
7
+ auth_credentials.yml
8
+ *.swp
9
+ .idea
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in salesforce_bulk_api_serial_or_parallel.gemspec
4
+ gemspec
data/LICENCE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Yatish Mehta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Salesforce-Bulk-Api
2
+ [![Gem Version](https://badge.fury.io/rb/salesforce_bulk_api.png)](http://badge.fury.io/rb/salesforce_bulk_api)
3
+ ## Overview
4
+
5
+ Salesforce bulk API is a simple ruby gem for connecting to and using the Salesforce Bulk API. It is actually a re-written code from [salesforce_bulk](https://github.com/jorgevaldivia/salesforce_bulk).Written to suit many more other features as well.
6
+
7
+ ## How to use
8
+
9
+ Using this gem is simple and straight forward.
10
+
11
+ To initialize:
12
+
13
+ `sudo gem install salesforce_bulk_api_serial_or_parallel`
14
+
15
+ or add
16
+
17
+ `gem salesforce_bulk_api_serial_or_parallel`
18
+
19
+ in your Gemfile
20
+
21
+ There are two ways to authenticate with SalesForce to use the Bulk API: databasedotcom & restforce.
22
+ Please check out the entire documentation of the gem you decide to use to learn the various ways of authentication.
23
+
24
+ [Databasedotcom](https://github.com/heroku/databasedotcom)
25
+ [Restforce](https://github.com/ejholmes/restforce)
26
+
27
+
28
+ You can use username password combo, OmniAuth, Oauth2
29
+ You can use as many records possible in the Array. Governor limits are taken care of inside the gem.
30
+
31
+
32
+ require 'salesforce_bulk_api_serial_or_parallel'
33
+ client = Databasedotcom::Client.new :client_id => SFDC_APP_CONFIG["client_id"], :client_secret => SFDC_APP_CONFIG["client_secret"] #client_id and client_secret respectively
34
+ client.authenticate :token => "my-oauth-token", :instance_url => "http://na1.salesforce.com" #=> "my-oauth-token"
35
+
36
+ salesforce = SalesforceBulkApi::Api.new(client)
37
+
38
+ OR
39
+
40
+ require 'salesforce_bulk_api_serial_or_parallel'
41
+ client = Restforce.new(
42
+ username: SFDC_APP_CONFIG['SFDC_USERNAME'],
43
+ password: SFDC_APP_CONFIG['SFDC_PASSWORD'],
44
+ security_token: SFDC_APP_CONFIG['SFDC_SECURITY_TOKEN'],
45
+ client_id: SFDC_APP_CONFIG['SFDC_CLIENT_ID'],
46
+ client_secret: SFDC_APP_CONFIG['SFDC_CLIENT_SECRET'].to_i,
47
+ host: SFDC_APP_CONFIG['SFDC_HOST']
48
+ )
49
+ client.authenticate!
50
+ salesforce = SalesforceBulkApi::Api.new(client)
51
+
52
+
53
+ ### Sample operations:
54
+
55
+ # Insert/Create
56
+ # Add as many fields per record as needed.
57
+ new_account = Hash["name" => "Test Account", "type" => "Other"]
58
+ records_to_insert = Array.new
59
+ # You can add as many records as you want here, just keep in mind that Salesforce has governor limits.
60
+ records_to_insert.push(new_account)
61
+ result = salesforce.create("Account", records_to_insert)
62
+ puts "result is: #{result.inspect}"
63
+
64
+ # Update
65
+ updated_account = Hash["name" => "Test Account -- Updated", id => "a00A0001009zA2m"] # Nearly identical to an insert, but we need to pass the salesforce id.
66
+ records_to_update = Array.new
67
+ records_to_update.push(updated_account)
68
+ salesforce.update("Account", records_to_update)
69
+
70
+ # Upsert
71
+ upserted_account = Hash["name" => "Test Account -- Upserted", "External_Field_Name" => "123456"] # Fields to be updated. External field must be included
72
+ records_to_upsert = Array.new
73
+ records_to_upsert.push(upserted_account)
74
+ salesforce.upsert("Account", records_to_upsert, "External_Field_Name") # Note that upsert accepts an extra parameter for the external field name
75
+
76
+ # Delete
77
+ deleted_account = Hash["id" => "a00A0001009zA2m"] # We only specify the id of the records to delete
78
+ records_to_delete = Array.new
79
+ records_to_delete.push(deleted_account)
80
+ salesforce.delete("Account", records_to_delete)
81
+
82
+ # Query
83
+ res = salesforce.query("Account", "select id, name, createddate from Account limit 3") # We just need to pass the sobject name and the query string
84
+
85
+ ### Helpful methods:
86
+
87
+ # Check status of a job via #job_from_id
88
+ job = salesforce.job_from_id('a00A0001009zA2m') # Returns a SalesforceBulkApi::Job instance
89
+ puts "status is: #{job.check_job_status.inspect}"
90
+
91
+ ### Listening to events:
92
+
93
+ # A job is created
94
+ # Useful when you need to store the job_id before any work begins, then if you fail during a complex load scenario, you can wait for your
95
+ # previous job(s) to finish.
96
+ salesforce.on_job_created do |job|
97
+ puts "Job #{job.job_id} created!"
98
+ end
99
+
100
+ ### Throttling API calls:
101
+
102
+ # By default, this gem (and maybe your app driving it) will query job/batch statuses at an unbounded rate. We
103
+ # can fix that, e.g.:
104
+ salesforce.connection.set_status_throttle(30) # only check status of individual jobs/batches every 30 seconds
105
+
106
+ ## Installation
107
+
108
+ sudo gem install salesforce_bulk_api_serial_or_parallel
109
+
110
+ ## Contribute
111
+
112
+ Feel to fork and send Pull request
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require 'rspec/core/rake_task'
2
+ task :default => :spec
3
+ RSpec::Core::RakeTask.new
@@ -0,0 +1,7 @@
1
+ salesforce:
2
+ client_id: client_id_here
3
+ client_secret: client_secret_here
4
+ user: sf_user@example.com
5
+ passwordandtoken: passandtokenhere
6
+ test_account_id: 0013000000ymMBh
7
+ host: 'login.salesforce.com' # use test.salesforce.com if it is a sandbox
@@ -0,0 +1,98 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ require 'net/https'
4
+ require 'xmlsimple'
5
+ require 'csv'
6
+
7
+ require 'salesforce_bulk_api_serial_or_parallel/version'
8
+ require 'salesforce_bulk_api_serial_or_parallel/concerns/throttling'
9
+ require 'salesforce_bulk_api_serial_or_parallel/job'
10
+ require 'salesforce_bulk_api_serial_or_parallel/connection'
11
+
12
+ module SalesforceBulkApiSerialOrParallel
13
+ class Api
14
+ attr_reader :connection
15
+
16
+ SALESFORCE_API_VERSION = '1.0.1'
17
+
18
+ def initialize(client)
19
+ @connection = SalesforceBulkApiSerialOrParallel::Connection.new(SALESFORCE_API_VERSION, client)
20
+ @listeners = { job_created: [] }
21
+ end
22
+
23
+ def upsert(sobject, records, external_field, get_response = false, send_nulls = false, no_null_list = [], batch_size = 10000, timeout = 1500, concurrency = nil)
24
+ do_operation('upsert', sobject, records, external_field, get_response, timeout, batch_size, send_nulls, no_null_list, concurrency)
25
+ end
26
+
27
+ def update(sobject, records, get_response = false, send_nulls = false, no_null_list = [], batch_size = 10000, timeout = 1500, concurrency = nil)
28
+ do_operation('update', sobject, records, nil, get_response, timeout, batch_size, send_nulls, no_null_list, concurrency)
29
+ end
30
+
31
+ def create(sobject, records, get_response = false, send_nulls = false, batch_size = 10000, timeout = 1500, concurrency = nil)
32
+ do_operation('insert', sobject, records, nil, get_response, timeout, batch_size, send_nulls, concurrency)
33
+ end
34
+
35
+ def delete(sobject, records, get_response = false, batch_size = 10000, timeout = 1500)
36
+ do_operation('delete', sobject, records, nil, get_response, timeout, batch_size, concurrency)
37
+ end
38
+
39
+ def query(sobject, query, batch_size = 10000, timeout = 1500)
40
+ do_operation('query', sobject, query, nil, true, timeout, batch_size)
41
+ end
42
+
43
+ def counters
44
+ {
45
+ http_get: @connection.counters[:get],
46
+ http_post: @connection.counters[:post],
47
+ upsert: get_counters[:upsert],
48
+ update: get_counters[:update],
49
+ create: get_counters[:create],
50
+ delete: get_counters[:delete],
51
+ query: get_counters[:query]
52
+ }
53
+ end
54
+
55
+
56
+ ##
57
+ # Allows you to attach a listener that accepts the created job (which has a useful #job_id field). This is useful
58
+ # for recording a job ID persistently before you begin batch work (i.e. start modifying the salesforce database),
59
+ # so if the load process you are writing needs to recover, it can be aware of previous jobs it started and wait
60
+ # for them to finish.
61
+ def on_job_created(&block)
62
+ @listeners[:job_created] << block
63
+ end
64
+
65
+ def job_from_id(job_id)
66
+ SalesforceBulkApiSerialOrParallel::Job.new(job_id: job_id, connection: @connection)
67
+ end
68
+
69
+ def do_operation(operation, sobject, records, external_field, get_response, timeout, batch_size, send_nulls = false, no_null_list = [], concurrency_mode = nil)
70
+ count operation.to_sym
71
+
72
+ job = SalesforceBulkApiSerialOrParallel::Job.new(
73
+ operation: operation,
74
+ sobject: sobject,
75
+ records: records,
76
+ external_field: external_field,
77
+ connection: @connection
78
+ )
79
+
80
+ job.create_job(batch_size, send_nulls, no_null_list, concurrency_mode)
81
+ @listeners[:job_created].each {|callback| callback.call(job)}
82
+ operation == "query" ? job.add_query() : job.add_batches()
83
+ response = job.close_job
84
+ response.merge!({'batches' => job.get_job_result(get_response, timeout)}) if get_response == true
85
+ response
86
+ end
87
+
88
+ private
89
+ def get_counters
90
+ @counters ||= Hash.new(0)
91
+ end
92
+
93
+ def count(name)
94
+ get_counters[name] += 1
95
+ end
96
+
97
+ end
98
+ end
@@ -0,0 +1,60 @@
1
+ module SalesforceBulkApiSerialOrParallel::Concerns
2
+ module Throttling
3
+
4
+ def throttles
5
+ @throttles.dup
6
+ end
7
+
8
+ def add_throttle(&throttling_callback)
9
+ @throttles ||= []
10
+ @throttles << throttling_callback
11
+ end
12
+
13
+ def set_status_throttle(limit_seconds)
14
+ set_throttle_limit_in_seconds(limit_seconds, [:http_method, :path], ->(details) { details[:http_method] == :get })
15
+ end
16
+
17
+ def set_throttle_limit_in_seconds(limit_seconds, throttle_by_keys, only_if)
18
+ add_throttle do |details|
19
+ limit_log = get_limit_log(Time.now - limit_seconds)
20
+ key = extract_constraint_key_from(details, throttle_by_keys)
21
+ last_request = limit_log[key]
22
+
23
+ if !last_request.nil? && only_if.call(details)
24
+ seconds_since_last_request = Time.now.to_f - last_request.to_f
25
+ need_to_wait_seconds = limit_seconds - seconds_since_last_request
26
+ sleep(need_to_wait_seconds) if need_to_wait_seconds > 0
27
+ end
28
+
29
+ limit_log[key] = Time.now
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def extract_constraint_key_from(details, throttle_by_keys)
36
+ hash = {}
37
+ throttle_by_keys.each { |k| hash[k] = details[k] }
38
+ hash
39
+ end
40
+
41
+ def get_limit_log(prune_older_than)
42
+ @limits ||= Hash.new(0)
43
+
44
+ @limits.delete_if do |k, v|
45
+ v < prune_older_than
46
+ end
47
+
48
+ @limits
49
+ end
50
+
51
+ def throttle(details={})
52
+ (@throttles || []).each do |callback|
53
+ args = [details]
54
+ args = args[0..callback.arity]
55
+ callback.call(*args)
56
+ end
57
+ end
58
+
59
+ end
60
+ end
@@ -0,0 +1,98 @@
1
+ module SalesforceBulkApiSerialOrParallel
2
+ require 'timeout'
3
+
4
+ class Connection
5
+ include Concerns::Throttling
6
+
7
+ LOGIN_HOST = 'login.salesforce.com'
8
+
9
+ def initialize(api_version, client)
10
+ @client = client
11
+ @api_version = api_version
12
+ @path_prefix = "/services/async/#{@api_version}/"
13
+
14
+ login()
15
+ end
16
+
17
+ def login()
18
+ client_type = @client.class.to_s
19
+ case client_type
20
+ when "Restforce::Data::Client"
21
+ @session_id = @client.options[:oauth_token]
22
+ @server_url = @client.options[:instance_url]
23
+ else
24
+ @session_id = @client.oauth_token
25
+ @server_url = @client.instance_url
26
+ end
27
+ @instance = parse_instance()
28
+ @instance_host = "#{@instance}.salesforce.com"
29
+ end
30
+
31
+ def post_xml(host, path, xml, headers)
32
+ host = host || @instance_host
33
+ if host != LOGIN_HOST # Not login, need to add session id to header
34
+ headers['X-SFDC-Session'] = @session_id
35
+ path = "#{@path_prefix}#{path}"
36
+ end
37
+ i = 0
38
+ begin
39
+ count :post
40
+ throttle(http_method: :post, path: path)
41
+ https(host).post(path, xml, headers).body
42
+ rescue
43
+ i += 1
44
+ if i < 3
45
+ puts "Request fail #{i}: Retrying #{path}"
46
+ retry
47
+ else
48
+ puts "FATAL: Request to #{path} failed three times."
49
+ raise
50
+ end
51
+ end
52
+ end
53
+
54
+ def get_request(host, path, headers)
55
+ host = host || @instance_host
56
+ path = "#{@path_prefix}#{path}"
57
+ if host != LOGIN_HOST # Not login, need to add session id to header
58
+ headers['X-SFDC-Session'] = @session_id;
59
+ end
60
+
61
+ count :get
62
+ throttle(http_method: :get, path: path)
63
+ https(host).get(path, headers).body
64
+ end
65
+
66
+ def https(host)
67
+ req = Net::HTTP.new(host, 443)
68
+ req.use_ssl = true
69
+ req.verify_mode = OpenSSL::SSL::VERIFY_NONE
70
+ req
71
+ end
72
+
73
+ def parse_instance()
74
+ @instance = @server_url.match(/https:\/\/[a-z]{2}[0-9]{1,2}/).to_s.gsub("https://","")
75
+ @instance = @server_url.split(".salesforce.com")[0].split("://")[1] if @instance.nil? || @instance.empty?
76
+ return @instance
77
+ end
78
+
79
+ def counters
80
+ {
81
+ get: get_counters[:get],
82
+ post: get_counters[:post]
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def get_counters
89
+ @counters ||= Hash.new(0)
90
+ end
91
+
92
+ def count(http_method)
93
+ get_counters[http_method] += 1
94
+ end
95
+
96
+ end
97
+
98
+ end
@@ -0,0 +1,235 @@
1
+ module SalesforceBulkApiSerialOrParallel
2
+
3
+ class Job
4
+ attr_reader :job_id
5
+
6
+ class SalesforceException < StandardError; end
7
+
8
+ def initialize(args)
9
+ @job_id = args[:job_id]
10
+ @operation = args[:operation]
11
+ @sobject = args[:sobject]
12
+ @external_field = args[:external_field]
13
+ @records = args[:records]
14
+ @connection = args[:connection]
15
+ @batch_ids = []
16
+ @XML_HEADER = '<?xml version="1.0" encoding="utf-8" ?>'
17
+ end
18
+
19
+ def create_job(batch_size, send_nulls, no_null_list, concurrency_mode)
20
+ @batch_size = batch_size
21
+ @send_nulls = send_nulls
22
+ @no_null_list = no_null_list
23
+ @concurrency_mode = concurrency_mode
24
+
25
+ xml = "#{@XML_HEADER}<jobInfo xmlns=\"http://www.force.com/2009/06/asyncapi/dataload\">"
26
+ xml += "<operation>#{@operation}</operation>"
27
+ xml += "<object>#{@sobject}</object>"
28
+ if @concurrency_mode
29
+ xml += "<concurrencyMode>#{@concurrency_mode}</concurrencyMode>"
30
+ end
31
+ # This only happens on upsert
32
+ if !@external_field.nil?
33
+ xml += "<externalIdFieldName>#{@external_field}</externalIdFieldName>"
34
+ end
35
+ xml += "<contentType>XML</contentType>"
36
+ xml += "</jobInfo>"
37
+
38
+ path = "job"
39
+ headers = Hash['Content-Type' => 'application/xml; charset=utf-8']
40
+
41
+ response = @connection.post_xml(nil, path, xml, headers)
42
+ response_parsed = XmlSimple.xml_in(response)
43
+
44
+ # response may contain an exception, so raise it
45
+ raise SalesforceException.new("#{response_parsed['exceptionMessage'][0]} (#{response_parsed['exceptionCode'][0]})") if response_parsed['exceptionCode']
46
+
47
+ @job_id = response_parsed['id'][0]
48
+ end
49
+
50
+ def close_job()
51
+ xml = "#{@XML_HEADER}<jobInfo xmlns=\"http://www.force.com/2009/06/asyncapi/dataload\">"
52
+ xml += "<state>Closed</state>"
53
+ xml += "</jobInfo>"
54
+
55
+ path = "job/#{@job_id}"
56
+ headers = Hash['Content-Type' => 'application/xml; charset=utf-8']
57
+
58
+ response = @connection.post_xml(nil, path, xml, headers)
59
+ XmlSimple.xml_in(response)
60
+ end
61
+
62
+ def add_query
63
+ path = "job/#{@job_id}/batch/"
64
+ headers = Hash["Content-Type" => "application/xml; charset=UTF-8"]
65
+
66
+ response = @connection.post_xml(nil, path, @records, headers)
67
+ response_parsed = XmlSimple.xml_in(response)
68
+
69
+ @batch_ids << response_parsed['id'][0]
70
+ end
71
+
72
+ def add_batches
73
+ raise 'Records must be an array of hashes.' unless @records.is_a? Array
74
+ keys = @records.reduce({}) {|h, pairs| pairs.each {|k, v| (h[k] ||= []) << v}; h}.keys
75
+
76
+ @records_dup = @records.clone
77
+
78
+ super_records = []
79
+ (@records_dup.size / @batch_size).to_i.times do
80
+ super_records << @records_dup.pop(@batch_size)
81
+ end
82
+ super_records << @records_dup unless @records_dup.empty?
83
+
84
+ super_records.each do |batch|
85
+ @batch_ids << add_batch(keys, batch)
86
+ end
87
+ end
88
+
89
+ def add_batch(keys, batch)
90
+ xml = "#{@XML_HEADER}<sObjects xmlns=\"http://www.force.com/2009/06/asyncapi/dataload\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">"
91
+ batch.each do |r|
92
+ xml += create_sobject(keys, r)
93
+ end
94
+ xml += '</sObjects>'
95
+ path = "job/#{@job_id}/batch/"
96
+ headers = Hash["Content-Type" => "application/xml; charset=UTF-8"]
97
+ response = @connection.post_xml(nil, path, xml, headers)
98
+ response_parsed = XmlSimple.xml_in(response)
99
+ response_parsed['id'][0] if response_parsed['id']
100
+ end
101
+
102
+ def build_sobject(data)
103
+ xml = '<sObject>'
104
+ data.keys.each do |k|
105
+ if k.is_a?(Hash)
106
+ xml += build_sobject(k)
107
+ elsif data[k] != :type
108
+ xml += "<#{k}>#{data[k]}</#{k}>"
109
+ end
110
+ end
111
+ xml += '</sObject>'
112
+ end
113
+
114
+ def create_sobject(keys, r)
115
+ sobject_xml = '<sObject>'
116
+ keys.each do |k|
117
+ if r[k].is_a?(Hash)
118
+ sobject_xml += "<#{k}>"
119
+ sobject_xml += build_sobject(r[k])
120
+ sobject_xml += "</#{k}>"
121
+ elsif !r[k].to_s.empty?
122
+ sobject_xml += "<#{k}>"
123
+ if r[k].respond_to?(:encode)
124
+ sobject_xml += r[k].encode(:xml => :text)
125
+ elsif r[k].respond_to?(:iso8601) # timestamps
126
+ sobject_xml += r[k].iso8601.to_s
127
+ else
128
+ sobject_xml += r[k].to_s
129
+ end
130
+ sobject_xml += "</#{k}>"
131
+ elsif @send_nulls && !@no_null_list.include?(k)
132
+ sobject_xml += "<#{k} xsi:nil=\"true\"/>"
133
+ end
134
+ end
135
+ sobject_xml += '</sObject>'
136
+ sobject_xml
137
+ end
138
+
139
+ def check_job_status
140
+ path = "job/#{@job_id}"
141
+ headers = Hash.new
142
+ response = @connection.get_request(nil, path, headers)
143
+
144
+ begin
145
+ response_parsed = XmlSimple.xml_in(response) if response
146
+ response_parsed
147
+ rescue StandardError => e
148
+ puts "Error parsing XML response for #{@job_id}"
149
+ puts e
150
+ puts e.backtrace
151
+ end
152
+ end
153
+
154
+ def check_batch_status(batch_id)
155
+ path = "job/#{@job_id}/batch/#{batch_id}"
156
+ headers = Hash.new
157
+
158
+ response = @connection.get_request(nil, path, headers)
159
+
160
+ begin
161
+ response_parsed = XmlSimple.xml_in(response) if response
162
+ response_parsed
163
+ rescue StandardError => e
164
+ puts "Error parsing XML response for #{@job_id}, batch #{batch_id}"
165
+ puts e
166
+ puts e.backtrace
167
+ end
168
+ end
169
+
170
+ def get_job_result(return_result, timeout)
171
+ # timeout is in seconds
172
+ begin
173
+ state = []
174
+ Timeout::timeout(timeout, SalesforceBulkApiSerialOrParallel::JobTimeout) do
175
+ while true
176
+ job_status = self.check_job_status
177
+ if job_status && job_status['state'] && job_status['state'][0] == 'Closed'
178
+ batch_statuses = {}
179
+
180
+ batches_ready = @batch_ids.all? do |batch_id|
181
+ batch_state = batch_statuses[batch_id] = self.check_batch_status(batch_id)
182
+ batch_state && batch_state['state'] && batch_state['state'][0] && !['Queued', 'InProgress'].include?(batch_state['state'][0])
183
+ end
184
+
185
+ if batches_ready
186
+ @batch_ids.each do |batch_id|
187
+ state.insert(0, batch_statuses[batch_id])
188
+ @batch_ids.delete(batch_id)
189
+ end
190
+ end
191
+ break if @batch_ids.empty?
192
+ else
193
+ break
194
+ end
195
+ end
196
+ end
197
+ rescue SalesforceBulkApiSerialOrParallel::JobTimeout => e
198
+ puts 'Timeout waiting for Salesforce to process job batches #{@batch_ids} of job #{@job_id}.'
199
+ puts e
200
+ raise
201
+ end
202
+
203
+ state.each_with_index do |batch_state, i|
204
+ if batch_state['state'][0] == 'Completed' && return_result == true
205
+ state[i].merge!({'response' => self.get_batch_result(batch_state['id'][0])})
206
+ end
207
+ end
208
+ state
209
+ end
210
+
211
+ def get_batch_result(batch_id)
212
+ path = "job/#{@job_id}/batch/#{batch_id}/result"
213
+ headers = Hash["Content-Type" => "application/xml; charset=UTF-8"]
214
+
215
+ response = @connection.get_request(nil, path, headers)
216
+ response_parsed = XmlSimple.xml_in(response)
217
+ results = response_parsed['result'] unless @operation == 'query'
218
+
219
+ if(@operation == 'query') # The query op requires us to do another request to get the results
220
+ result_id = response_parsed["result"][0]
221
+ path = "job/#{@job_id}/batch/#{batch_id}/result/#{result_id}"
222
+ headers = Hash.new
223
+ headers = Hash["Content-Type" => "application/xml; charset=UTF-8"]
224
+ response = @connection.get_request(nil, path, headers)
225
+ response_parsed = XmlSimple.xml_in(response)
226
+ results = response_parsed['records']
227
+ end
228
+ results
229
+ end
230
+
231
+ end
232
+
233
+ class JobTimeout < StandardError
234
+ end
235
+ end
@@ -0,0 +1,3 @@
1
+ module SalesforceBulkApiSerialOrParallel
2
+ VERSION = '0.1'
3
+ end
@@ -0,0 +1,30 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "salesforce_bulk_api_serial_or_parallel/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'salesforce_bulk_api_serial_or_parallel'
7
+ s.version = SalesforceBulkApiSerialOrParallel::VERSION
8
+ s.authors = ['Brendan Keogh']
9
+ s.email = ['bkeogh123@gmail.com']
10
+
11
+ s.homepage = 'https://github.com/beekermememe/salesforce_bulk_api_serial_or_parallel'
12
+ s.summary = %q{It uses the bulk api of salesforce to communicate with Salesforce CRM}
13
+ s.description = %q{Salesforce Bulk API with governor limits taken care of, this is a fork off of yatish27/salesforce_bulk_api_serial_or_parallel that just adds serial/parallel concurrency support}
14
+
15
+ s.rubyforge_project = 'salesforce_bulk_api_serial_or_parallel'
16
+
17
+ s.add_dependency('json', ['>= 0'])
18
+ s.add_dependency('xml-simple', ['>= 0'])
19
+
20
+ s.add_development_dependency 'rspec'
21
+ s.add_development_dependency 'restforce', '~> 1.5.1'
22
+ s.add_development_dependency 'rake', '~> 10.4.2'
23
+ s.add_development_dependency 'pry'
24
+
25
+ s.files = `git ls-files`.split("\n")
26
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
27
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
28
+ s.require_paths = ['lib']
29
+
30
+ end
@@ -0,0 +1,193 @@
1
+ require 'spec_helper'
2
+ require 'yaml'
3
+ require 'restforce'
4
+
5
+ describe SalesforceBulkApiSerialOrParallel do
6
+
7
+ before :each do
8
+ auth_hash = YAML.load_file('auth_credentials.yml')
9
+ sfdc_auth_hash = auth_hash['salesforce']
10
+
11
+ @sf_client = Restforce.new(
12
+ username: sfdc_auth_hash['user'],
13
+ password: sfdc_auth_hash['passwordandtoken'],
14
+ client_id: sfdc_auth_hash['client_id'],
15
+ client_secret: sfdc_auth_hash['client_secret'],
16
+ host: sfdc_auth_hash['host'])
17
+ @sf_client.authenticate!
18
+
19
+ @account_id = auth_hash['salesforce']['test_account_id']
20
+
21
+ @api = SalesforceBulkApiSerialOrParallel::Api.new(@sf_client)
22
+ end
23
+
24
+ after :each do
25
+
26
+ end
27
+
28
+ describe 'upsert' do
29
+
30
+ context 'when not passed get_result' do
31
+ it "doesn't return the batches array" do
32
+ res = @api.upsert('Account', [{:Id => @account_id, :Website => 'www.test.com'}], 'Id')
33
+ res['batches'].should be_nil
34
+ end
35
+ end
36
+
37
+ context 'when passed get_result = true' do
38
+ it 'returns the batches array' do
39
+ res = @api.upsert('Account', [{:Id => @account_id, :Website => 'www.test.com'}], 'Id', true)
40
+ res['batches'][0]['response'].is_a? Array
41
+
42
+ res['batches'][0]['response'][0]['id'][0].should start_with(@account_id)
43
+ res['batches'][0]['response'][0]['success'].should eq ['true']
44
+ res['batches'][0]['response'][0]['created'].should eq ['false']
45
+
46
+ end
47
+ end
48
+
49
+ context 'when passed send_nulls = true' do
50
+ it 'sets the nil and empty attributes to NULL' do
51
+ @api.update('Account', [{:Id => @account_id, :Website => 'abc123', :Phone => '5678'}], true)
52
+ res = @api.query('Account', "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'")
53
+ res['batches'][0]['response'][0]['Website'][0].should eq 'abc123'
54
+ res['batches'][0]['response'][0]['Phone'][0].should eq '5678'
55
+ res = @api.upsert('Account', [{:Id => @account_id, :Website => '', :Phone => nil}], 'Id', true, true)
56
+ res['batches'][0]['response'][0]['id'][0].should start_with(@account_id)
57
+ res['batches'][0]['response'][0]['success'].should eq ['true']
58
+ res['batches'][0]['response'][0]['created'].should eq ['false']
59
+ res = @api.query('Account', "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'")
60
+ res['batches'][0]['response'][0]['Website'][0].should eq({"xsi:nil" => "true"})
61
+ res['batches'][0]['response'][0]['Phone'][0].should eq({"xsi:nil" => "true"})
62
+ end
63
+ end
64
+
65
+ context 'when passed send_nulls = true and an array of fields not to null' do
66
+ it 'sets the nil and empty attributes to NULL, except for those included in the list of fields to ignore' do
67
+ @api.update('Account', [{:Id => @account_id, :Website => 'abc123', :Phone => '5678'}], true)
68
+ res = @api.query('Account', "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'")
69
+ res['batches'][0]['response'][0]['Website'][0].should eq 'abc123'
70
+ res['batches'][0]['response'][0]['Phone'][0].should eq '5678'
71
+ res = @api.upsert('Account', [{:Id => @account_id, :Website => '', :Phone => nil}], 'Id', true, true, [:Website, :Phone])
72
+ res['batches'][0]['response'][0]['id'][0].should start_with(@account_id)
73
+ res['batches'][0]['response'][0]['success'].should eq ['true']
74
+ res['batches'][0]['response'][0]['created'].should eq ['false']
75
+ res = @api.query('Account', "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'")
76
+ res['batches'][0]['response'][0]['Website'][0].should eq('abc123')
77
+ res['batches'][0]['response'][0]['Phone'][0].should eq('5678')
78
+ end
79
+ end
80
+
81
+ end
82
+
83
+ describe 'update' do
84
+ context 'when there is not an error' do
85
+ context 'when not passed get_result' do
86
+ it "doesnt return the batches array" do
87
+ res = @api.update('Account', [{:Id => @account_id, :Website => 'www.test.com'}])
88
+ res['batches'].should be_nil
89
+ end
90
+ end
91
+
92
+ context 'when passed get_result = true' do
93
+ it 'returns the batches array' do
94
+ res = @api.update('Account', [{:Id => @account_id, :Website => 'www.test.com'}], true)
95
+ res['batches'][0]['response'].is_a? Array
96
+ res['batches'][0]['response'][0]['id'][0].should start_with(@account_id)
97
+ res['batches'][0]['response'][0]['success'].should eq ['true']
98
+ res['batches'][0]['response'][0]['created'].should eq ['false']
99
+ end
100
+ end
101
+ end
102
+
103
+ context 'when there is an error' do
104
+ context 'when not passed get_result' do
105
+ it "doesn't return the results array" do
106
+ res = @api.update('Account', [{:Id => @account_id, :Website => 'www.test.com'},{:Id => 'abc123', :Website => 'www.test.com'}])
107
+ res['batches'].should be_nil
108
+ end
109
+ end
110
+
111
+ context 'when passed get_result = true with batches' do
112
+ it 'returns the results array' do
113
+ res = @api.update('Account', [{:Id => @account_id, :Website => 'www.test.com'}, {:Id => @account_id, :Website => 'www.test.com'}, {:Id => @account_id, :Website => 'www.test.com'}, {:Id => 'abc123', :Website => 'www.test.com'}], true, false, [], 2)
114
+
115
+ res['batches'][0]['response'][0]['id'][0].should start_with(@account_id)
116
+ res['batches'][0]['response'][0]['success'].should eq ['true']
117
+ res['batches'][0]['response'][0]['created'].should eq ['false']
118
+ res['batches'][0]['response'][1]['id'][0].should start_with(@account_id)
119
+ res['batches'][0]['response'][1]['success'].should eq ['true']
120
+ res['batches'][0]['response'][1]['created'].should eq ['false']
121
+
122
+ res['batches'][1]['response'][0]['id'][0].should start_with(@account_id)
123
+ res['batches'][1]['response'][0]['success'].should eq ['true']
124
+ res['batches'][1]['response'][0]['created'].should eq ['false']
125
+ res['batches'][1]['response'][1].should eq({"errors"=>[{"fields"=>["Id"], "message"=>["Account ID: id value of incorrect type: abc123"], "statusCode"=>["MALFORMED_ID"]}], "success"=>["false"], "created"=>["false"]})
126
+ end
127
+ end
128
+ end
129
+
130
+ end
131
+
132
+ describe 'create' do
133
+ pending
134
+ end
135
+
136
+ describe 'delete' do
137
+ pending
138
+ end
139
+
140
+ describe 'query' do
141
+
142
+ context 'when there are results' do
143
+ it 'returns the query results' do
144
+ res = @api.query('Account', "SELECT id, Name From Account WHERE Name LIKE 'Test%'")
145
+ res['batches'][0]['response'].length.should > 1
146
+ res['batches'][0]['response'][0]['Id'].should_not be_nil
147
+ end
148
+
149
+ context 'and there are multiple batches' do
150
+ # need dev to create > 10k records in dev organization
151
+ it 'returns the query results in a merged hash'
152
+ end
153
+ end
154
+
155
+ context 'when there are no results' do
156
+ it 'returns nil' do
157
+ res = @api.query('Account', "SELECT id From Account WHERE Name = 'ABC'")
158
+ res['batches'][0]['response'].should eq nil
159
+ end
160
+ end
161
+
162
+ context 'when there is an error' do
163
+ it 'returns nil' do
164
+ res = @api.query('Account', "SELECT id From Account WHERE Name = ''ABC'")
165
+ res['batches'][0]['response'].should eq nil
166
+ end
167
+ end
168
+
169
+ end
170
+
171
+ describe 'counters' do
172
+ context 'when read operations are called' do
173
+ it 'increments operation count and http GET count' do
174
+ @api.counters[:http_get].should eq 0
175
+ @api.counters[:query].should eq 0
176
+ @api.query('Account', "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'")
177
+ @api.counters[:http_get].should eq 1
178
+ @api.counters[:query].should eq 1
179
+ end
180
+ end
181
+
182
+ context 'when update operations are called' do
183
+ it 'increments operation count and http POST count' do
184
+ @api.counters[:http_post].should eq 0
185
+ @api.counters[:update].should eq 0
186
+ @api.update('Account', [{:Id => @account_id, :Website => 'abc123', :Phone => '5678'}], true)
187
+ @api.counters[:http_post].should eq 1
188
+ @api.counters[:update].should eq 1
189
+ end
190
+ end
191
+ end
192
+
193
+ end
@@ -0,0 +1,8 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'salesforce_bulk_api_serial_or_parallel'
4
+
5
+ RSpec.configure do |c|
6
+ c.filter_run :focus => true
7
+ c.run_all_when_everything_filtered = true
8
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: salesforce_bulk_api_serial_or_parallel
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ platform: ruby
6
+ authors:
7
+ - Brendan Keogh
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-05-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: xml-simple
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: restforce
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.5.1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.5.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 10.4.2
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 10.4.2
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Salesforce Bulk API with governor limits taken care of, this is a fork
98
+ off of yatish27/salesforce_bulk_api_serial_or_parallel that just adds serial/parallel
99
+ concurrency support
100
+ email:
101
+ - bkeogh123@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - ".gitignore"
107
+ - ".rspec"
108
+ - Gemfile
109
+ - LICENCE
110
+ - README.md
111
+ - Rakefile
112
+ - auth_credentials.yml.example
113
+ - lib/salesforce_bulk_api_serial_or_parallel.rb
114
+ - lib/salesforce_bulk_api_serial_or_parallel/concerns/throttling.rb
115
+ - lib/salesforce_bulk_api_serial_or_parallel/connection.rb
116
+ - lib/salesforce_bulk_api_serial_or_parallel/job.rb
117
+ - lib/salesforce_bulk_api_serial_or_parallel/version.rb
118
+ - salesforce_bulk_api_serial_or_parallel.gemspec
119
+ - spec/salesforce_bulk_api_serial_or_parallel/salesforce_bulk_api_serial_or_parallel.rb
120
+ - spec/spec_helper.rb
121
+ homepage: https://github.com/beekermememe/salesforce_bulk_api_serial_or_parallel
122
+ licenses: []
123
+ metadata: {}
124
+ post_install_message:
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubyforge_project: salesforce_bulk_api_serial_or_parallel
140
+ rubygems_version: 2.6.13
141
+ signing_key:
142
+ specification_version: 4
143
+ summary: It uses the bulk api of salesforce to communicate with Salesforce CRM
144
+ test_files:
145
+ - spec/salesforce_bulk_api_serial_or_parallel/salesforce_bulk_api_serial_or_parallel.rb
146
+ - spec/spec_helper.rb