├── .gitignore ├── .rspec ├── Gemfile ├── LICENCE ├── README.md ├── Rakefile ├── auth_credentials.yml.example ├── lib ├── salesforce_bulk_api.rb └── salesforce_bulk_api │ ├── concerns │ └── throttling.rb │ ├── connection.rb │ ├── job.rb │ └── version.rb ├── salesforce_bulk_api.gemspec └── spec ├── salesforce_bulk_api └── salesforce_bulk_api_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | .ruby-gemset 5 | .ruby-version 6 | pkg/* 7 | auth_credentials.yml 8 | *.swp 9 | .idea 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in salesforce_bulk_api.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Salesforce-Bulk-Api 2 | 3 | [![Gem Version](https://badge.fury.io/rb/salesforce_bulk_api.png)](http://badge.fury.io/rb/salesforce_bulk_api) 4 | 5 | ## Table of Contents 6 | 7 | - [Overview](#overview) 8 | - [Installation](#installation) 9 | - [Authentication](#authentication) 10 | - [Usage](#usage) 11 | - [Basic Operations](#basic-operations) 12 | - [Job Management](#job-management) 13 | - [Event Listening](#event-listening) 14 | - [Retrieving Batch Records](#retrieving-batch-records) 15 | - [API Call Throttling](#api-call-throttling) 16 | - [Contributing](#contributing) 17 | - [License](#license) 18 | 19 | ## Overview 20 | 21 | `SalesforceBulkApi` is a Ruby wrapper for the Salesforce Bulk API. It is rewritten from [salesforce_bulk](https://github.com/jorgevaldivia/salesforce_bulk) and adds several missing features, making it easier to perform bulk operations with Salesforce from Ruby applications. 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'salesforce_bulk_api' 29 | ``` 30 | 31 | And then execute: 32 | 33 | ``` 34 | bundle install 35 | ``` 36 | 37 | Or install it directly: 38 | 39 | ``` 40 | gem install salesforce_bulk_api 41 | ``` 42 | 43 | ## Authentication 44 | 45 | You can authenticate with Salesforce using either `databasedotcom` or `restforce` gems. Both support various authentication methods including username/password, OmniAuth, and OAuth2. 46 | 47 | Please refer to the documentation of these gems for detailed authentication options: 48 | 49 | - [Databasedotcom](https://github.com/heroku/databasedotcom) 50 | - [Restforce](https://github.com/ejholmes/restforce) 51 | 52 | ### Authentication Examples 53 | 54 | #### Using Databasedotcom: 55 | 56 | ```ruby 57 | require 'salesforce_bulk_api' 58 | 59 | client = Databasedotcom::Client.new( 60 | client_id: SFDC_APP_CONFIG["client_id"], 61 | client_secret: SFDC_APP_CONFIG["client_secret"] 62 | ) 63 | client.authenticate( 64 | token: " ", 65 | instance_url: "http://na1.salesforce.com" 66 | ) 67 | 68 | salesforce = SalesforceBulkApi::Api.new(client) 69 | ``` 70 | 71 | #### Using Restforce: 72 | 73 | ```ruby 74 | require 'salesforce_bulk_api' 75 | 76 | client = Restforce.new( 77 | username: SFDC_APP_CONFIG['SFDC_USERNAME'], 78 | password: SFDC_APP_CONFIG['SFDC_PASSWORD'], 79 | security_token: SFDC_APP_CONFIG['SFDC_SECURITY_TOKEN'], 80 | client_id: SFDC_APP_CONFIG['SFDC_CLIENT_ID'], 81 | client_secret: SFDC_APP_CONFIG['SFDC_CLIENT_SECRET'], 82 | host: SFDC_APP_CONFIG['SFDC_HOST'] 83 | ) 84 | client.authenticate! 85 | 86 | salesforce = SalesforceBulkApi::Api.new(client) 87 | ``` 88 | 89 | ## Usage 90 | 91 | ### Basic Operations 92 | 93 | #### Create/Insert Records 94 | 95 | ```ruby 96 | new_account = { "name" => "Test Account", "type" => "Other" } 97 | records_to_insert = [new_account] 98 | result = salesforce.create("Account", records_to_insert) 99 | puts "Result: #{result.inspect}" 100 | ``` 101 | 102 | #### Update Records 103 | 104 | ```ruby 105 | updated_account = { "name" => "Test Account -- Updated", "id" => "a00A0001009zA2m" } 106 | records_to_update = [updated_account] 107 | salesforce.update("Account", records_to_update) 108 | ``` 109 | 110 | #### Upsert Records 111 | 112 | ```ruby 113 | upserted_account = { "name" => "Test Account -- Upserted", "External_Field_Name" => "123456" } 114 | records_to_upsert = [upserted_account] 115 | salesforce.upsert("Account", records_to_upsert, "External_Field_Name") 116 | ``` 117 | 118 | #### Delete Records 119 | 120 | ```ruby 121 | deleted_account = { "id" => "a00A0001009zA2m" } 122 | records_to_delete = [deleted_account] 123 | salesforce.delete("Account", records_to_delete) 124 | ``` 125 | 126 | #### Query Records 127 | 128 | ```ruby 129 | res = salesforce.query("Account", "SELECT id, name, createddate FROM Account LIMIT 3") 130 | ``` 131 | 132 | ### Job Management 133 | 134 | You can check the status of a job using its ID: 135 | 136 | ```ruby 137 | job = salesforce.job_from_id('a00A0001009zA2m') 138 | puts "Status: #{job.check_job_status.inspect}" 139 | ``` 140 | 141 | ### Event Listening 142 | 143 | You can listen for job creation events: 144 | 145 | ```ruby 146 | salesforce.on_job_created do |job| 147 | puts "Job #{job.job_id} created!" 148 | end 149 | ``` 150 | 151 | ### Retrieving Batch Records 152 | 153 | Fetch records from a specific batch in a job: 154 | 155 | ```ruby 156 | job_id = 'l02A0231009Za8m' 157 | batch_id = 'H24a0708089zA2J' 158 | records = salesforce.get_batch_records(job_id, batch_id) 159 | ``` 160 | 161 | ### API Call Throttling 162 | 163 | You can control how frequently status checks are performed: 164 | 165 | ```ruby 166 | # Set status check interval to 30 seconds 167 | salesforce.connection.set_status_throttle(30) 168 | ``` 169 | 170 | ## Contributing 171 | 172 | We welcome contributions to improve this gem. Feel free to: 173 | 174 | 1. Fork the repository 175 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 176 | 3. Commit your changes (`git commit -am 'Add some amazing feature'`) 177 | 4. Push to the branch (`git push origin feature/amazing-feature`) 178 | 5. Create a new Pull Request 179 | 180 | ## License 181 | 182 | This project is licensed under the MIT License, Copyright (c) 2025 - see the [LICENCE](LICENCE) file for details. 183 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rspec/core/rake_task" 2 | task default: :spec 3 | RSpec::Core::RakeTask.new 4 | -------------------------------------------------------------------------------- /auth_credentials.yml.example: -------------------------------------------------------------------------------- 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 8 | -------------------------------------------------------------------------------- /lib/salesforce_bulk_api.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler" 3 | require "net/https" 4 | require "xmlsimple" 5 | require "csv" 6 | 7 | require "salesforce_bulk_api/version" 8 | require "salesforce_bulk_api/concerns/throttling" 9 | require "salesforce_bulk_api/job" 10 | require "salesforce_bulk_api/connection" 11 | 12 | module SalesforceBulkApi 13 | class Api 14 | attr_reader :connection 15 | 16 | def initialize(client, salesforce_api_version = "46.0") 17 | @connection = SalesforceBulkApi::Connection.new(salesforce_api_version, client) 18 | @listeners = {job_created: []} 19 | @counters = Hash.new(0) 20 | end 21 | 22 | %w[upsert update create delete].each do |operation| 23 | define_method(operation) do |sobject, records, external_field = nil, **options| 24 | do_operation(operation, sobject, records, external_field, **options) 25 | end 26 | end 27 | 28 | def query(sobject, query, **) 29 | do_operation("query", sobject, query, nil, get_response: true, **) 30 | end 31 | 32 | def counters 33 | { 34 | http_get: @connection.counters[:get], 35 | http_post: @connection.counters[:post], 36 | upsert: @counters[:upsert], 37 | update: @counters[:update], 38 | create: @counters[:create], 39 | delete: @counters[:delete], 40 | query: @counters[:query] 41 | } 42 | end 43 | 44 | # Allows you to attach a listener that accepts the created job (which has a useful #job_id field). 45 | # This is useful for recording a job ID persistently before you begin batch work (i.e. start modifying the salesforce database), 46 | # so if the load process you are writing needs to recover, it can be aware of previous jobs it started and wait 47 | # for them to finish. 48 | # 49 | def on_job_created(&block) 50 | @listeners[:job_created] << block 51 | end 52 | 53 | def job_from_id(job_id) 54 | SalesforceBulkApi::Job.new(job_id: job_id, connection: @connection) 55 | end 56 | 57 | private 58 | 59 | def do_operation(operation, sobject, records, external_field, **options) 60 | count(operation.to_sym) 61 | 62 | job = SalesforceBulkApi::Job.new( 63 | operation: operation, 64 | sobject: sobject, 65 | records: records, 66 | external_field: external_field, 67 | connection: @connection 68 | ) 69 | 70 | job.create_job(options[:batch_size], options[:send_nulls], options[:no_null_list]) 71 | @listeners[:job_created].each { |callback| callback.call(job) } 72 | 73 | (operation == "query") ? job.add_query : job.add_batches 74 | 75 | response = job.close_job 76 | response.merge!("batches" => job.get_job_result(options[:get_response], options[:timeout])) if options[:get_response] 77 | response 78 | end 79 | 80 | def count(name) 81 | @counters[name] += 1 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/salesforce_bulk_api/concerns/throttling.rb: -------------------------------------------------------------------------------- 1 | module SalesforceBulkApi::Concerns 2 | module Throttling 3 | def throttles 4 | @throttles.dup 5 | end 6 | 7 | def add_throttle(&throttling_callback) 8 | @throttles ||= [] 9 | @throttles << throttling_callback 10 | end 11 | 12 | def set_status_throttle(limit_seconds) 13 | set_throttle_limit_in_seconds(limit_seconds, [:http_method, :path], ->(details) { details[:http_method] == :get }) 14 | end 15 | 16 | def set_throttle_limit_in_seconds(limit_seconds, throttle_by_keys, only_if) 17 | add_throttle do |details| 18 | limit_log = get_limit_log(Time.now - limit_seconds) 19 | key = extract_constraint_key_from(details, throttle_by_keys) 20 | last_request = limit_log[key] 21 | 22 | if last_request && only_if.call(details) 23 | seconds_since_last_request = Time.now.to_f - last_request.to_f 24 | need_to_wait_seconds = limit_seconds - seconds_since_last_request 25 | sleep(need_to_wait_seconds) if need_to_wait_seconds.positive? 26 | end 27 | 28 | limit_log[key] = Time.now 29 | end 30 | end 31 | 32 | private 33 | 34 | def extract_constraint_key_from(details, throttle_by_keys) 35 | throttle_by_keys.each_with_object({}) { |k, hash| hash[k] = details[k] } 36 | end 37 | 38 | def get_limit_log(prune_older_than) 39 | @limits ||= {} 40 | @limits.delete_if { |_, v| v < prune_older_than } 41 | end 42 | 43 | def throttle(details = {}) 44 | (@throttles || []).each do |callback| 45 | callback.call(details) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/salesforce_bulk_api/connection.rb: -------------------------------------------------------------------------------- 1 | require "timeout" 2 | require "net/https" 3 | 4 | module SalesforceBulkApi 5 | class Connection 6 | include Concerns::Throttling 7 | 8 | LOGIN_HOST = "login.salesforce.com".freeze 9 | 10 | attr_reader :session_id, :server_url, :instance, :instance_host 11 | 12 | def initialize(api_version, client) 13 | @client = client 14 | @api_version = api_version 15 | @path_prefix = "/services/async/#{@api_version}/" 16 | @counters = Hash.new(0) 17 | 18 | login 19 | end 20 | 21 | def post_xml(host, path, xml, headers) 22 | host ||= @instance_host 23 | headers["X-SFDC-Session"] = @session_id unless host == LOGIN_HOST 24 | path = "#{@path_prefix}#{path}" unless host == LOGIN_HOST 25 | 26 | perform_request(:post, host, path, xml, headers) 27 | end 28 | 29 | def get_request(host, path, headers) 30 | host ||= @instance_host 31 | path = "#{@path_prefix}#{path}" 32 | headers["X-SFDC-Session"] = @session_id unless host == LOGIN_HOST 33 | 34 | perform_request(:get, host, path, nil, headers) 35 | end 36 | 37 | def counters 38 | { 39 | get: @counters[:get], 40 | post: @counters[:post] 41 | } 42 | end 43 | 44 | private 45 | 46 | def login 47 | client_type = @client.class.to_s 48 | @session_id, @server_url = if client_type == "Restforce::Data::Client" 49 | [@client.options[:oauth_token], @client.options[:instance_url]] 50 | else 51 | [@client.oauth_token, @client.instance_url] 52 | end 53 | @instance = parse_instance 54 | @instance_host = "#{@instance}.salesforce.com" 55 | end 56 | 57 | def perform_request(method, host, path, body, headers) 58 | retries = 0 59 | begin 60 | count(method) 61 | throttle(http_method: method, path: path) 62 | response = https(host).public_send(method, path, body, headers) 63 | response.body 64 | rescue => e 65 | retries += 1 66 | if retries < 3 67 | puts "Request fail #{retries}: Retrying #{path}" 68 | retry 69 | else 70 | puts "FATAL: Request to #{path} failed three times." 71 | raise e 72 | end 73 | end 74 | end 75 | 76 | def https(host) 77 | Net::HTTP.new(host, 443).tap do |http| 78 | http.use_ssl = true 79 | http.verify_mode = OpenSSL::SSL::VERIFY_NONE 80 | end 81 | end 82 | 83 | def count(http_method) 84 | @counters[http_method] += 1 85 | end 86 | 87 | def parse_instance 88 | instance = @server_url.match(%r{https://([a-z]{2}[0-9]{1,2})\.})&.captures&.first 89 | instance || @server_url.split(".salesforce.com").first.split("://").last 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/salesforce_bulk_api/job.rb: -------------------------------------------------------------------------------- 1 | require "timeout" 2 | 3 | module SalesforceBulkApi 4 | class Job 5 | attr_reader :job_id 6 | 7 | class SalesforceException < StandardError; end 8 | 9 | XML_HEADER = ''.freeze 10 | 11 | def initialize(args) 12 | @job_id = args[:job_id] 13 | @operation = args[:operation] 14 | @sobject = args[:sobject] 15 | @external_field = args[:external_field] 16 | @records = args[:records] 17 | @connection = args[:connection] 18 | @batch_ids = [] 19 | end 20 | 21 | def create_job(batch_size, send_nulls, no_null_list) 22 | @batch_size = batch_size 23 | @send_nulls = send_nulls 24 | @no_null_list = no_null_list 25 | 26 | xml = build_job_xml 27 | response = post_xml("job", xml) 28 | parse_job_response(response) 29 | end 30 | 31 | def close_job 32 | xml = build_close_job_xml 33 | response = post_xml("job/#{@job_id}", xml) 34 | XmlSimple.xml_in(response) 35 | end 36 | 37 | def add_query 38 | response = post_xml("job/#{@job_id}/batch/", @records) 39 | response_parsed = XmlSimple.xml_in(response) 40 | @batch_ids << response_parsed["id"][0] 41 | end 42 | 43 | def add_batches 44 | raise ArgumentError, "Records must be an array of hashes." unless @records.is_a?(Array) 45 | 46 | keys = @records.each_with_object({}) { |pairs, h| pairs.each { |k, v| (h[k] ||= []) << v } }.keys 47 | batches = @records.each_slice(@batch_size).to_a 48 | 49 | batches.each do |batch| 50 | @batch_ids << add_batch(keys, batch) 51 | end 52 | end 53 | 54 | def get_job_result(return_result, timeout) 55 | state = [] 56 | Timeout.timeout(timeout, JobTimeout) do 57 | loop do 58 | job_status = check_job_status 59 | break unless job_closed_and_batches_completed?(job_status, state) 60 | break if @batch_ids.empty? 61 | end 62 | end 63 | rescue JobTimeout => e 64 | handle_timeout(e) 65 | ensure 66 | process_batch_results(state) if return_result 67 | state 68 | end 69 | 70 | private 71 | 72 | def build_job_xml 73 | xml = "#{XML_HEADER}" 74 | xml << "#{@operation}" 75 | xml << "#{@sobject}" 76 | xml << "#{@external_field}" if @external_field 77 | xml << "XML" 78 | xml << "" 79 | end 80 | 81 | def build_close_job_xml 82 | "#{XML_HEADER}Closed" 83 | end 84 | 85 | def post_xml(path, xml) 86 | headers = {"Content-Type" => "application/xml; charset=utf-8"} 87 | @connection.post_xml(nil, path, xml, headers) 88 | end 89 | 90 | def parse_job_response(response) 91 | response_parsed = XmlSimple.xml_in(response) 92 | if response_parsed["exceptionCode"] 93 | raise SalesforceException, "#{response_parsed["exceptionMessage"][0]} (#{response_parsed["exceptionCode"][0]})" 94 | end 95 | @job_id = response_parsed["id"][0] 96 | end 97 | 98 | def add_batch(keys, batch) 99 | xml = "#{XML_HEADER}" 100 | batch.each { |r| xml << create_sobject(keys, r) } 101 | xml << "" 102 | 103 | response = post_xml("job/#{@job_id}/batch/", xml) 104 | response_parsed = XmlSimple.xml_in(response) 105 | response_parsed["id"]&.first 106 | end 107 | 108 | def job_closed_and_batches_completed?(job_status, state) 109 | return false unless job_status && job_status["state"] && job_status["state"][0] == "Closed" 110 | 111 | batch_statuses = {} 112 | batches_ready = @batch_ids.all? do |batch_id| 113 | batch_state = batch_statuses[batch_id] = check_batch_status(batch_id) 114 | batch_state && batch_state["state"] && batch_state["state"][0] && !["Queued", "InProgress"].include?(batch_state["state"][0]) 115 | end 116 | 117 | if batches_ready 118 | @batch_ids.each do |batch_id| 119 | state.unshift(batch_statuses[batch_id]) 120 | @batch_ids.delete(batch_id) 121 | end 122 | end 123 | 124 | true 125 | end 126 | 127 | def handle_timeout(error) 128 | puts "Timeout waiting for Salesforce to process job batches #{@batch_ids} of job #{@job_id}." 129 | puts error 130 | raise 131 | end 132 | 133 | def process_batch_results(state) 134 | state.each_with_index do |batch_state, i| 135 | if batch_state["state"][0] == "Completed" 136 | state[i].merge!("response" => get_batch_result(batch_state["id"][0])) 137 | end 138 | end 139 | end 140 | end 141 | 142 | class JobTimeout < StandardError; end 143 | end 144 | -------------------------------------------------------------------------------- /lib/salesforce_bulk_api/version.rb: -------------------------------------------------------------------------------- 1 | module SalesforceBulkApi 2 | VERSION = "1.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /salesforce_bulk_api.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "salesforce_bulk_api/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "salesforce_bulk_api" 6 | s.version = SalesforceBulkApi::VERSION 7 | s.authors = ["Yatish Mehta"] 8 | s.email = ["yatish27@users.noreply.github.com"] 9 | 10 | s.homepage = "https://github.com/yatishmehta27/salesforce_bulk_api" 11 | s.summary = "It uses the bulk api of salesforce to communicate with Salesforce CRM" 12 | s.description = "Salesforce Bulk API with governor limits taken care of" 13 | 14 | s.add_dependency("json", [">= 0"]) 15 | s.add_dependency("xml-simple", [">= 0"]) 16 | s.add_dependency("csv", [">= 0"]) 17 | 18 | s.add_development_dependency "rspec" 19 | s.add_development_dependency "restforce", "~> 3.0.0" 20 | s.add_development_dependency "rake", ">= 12.3.3" 21 | s.add_development_dependency "pry" 22 | s.add_development_dependency "standardrb" 23 | 24 | s.files = `git ls-files`.split("\n") 25 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 26 | s.require_paths = ["lib"] 27 | end 28 | -------------------------------------------------------------------------------- /spec/salesforce_bulk_api/salesforce_bulk_api_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "yaml" 3 | require "restforce" 4 | 5 | describe SalesforceBulkApi do 6 | before :each do 7 | auth_hash = YAML.load_file("auth_credentials.yml") 8 | sfdc_auth_hash = auth_hash["salesforce"] 9 | 10 | @sf_client = Restforce.new( 11 | username: sfdc_auth_hash["user"], 12 | password: sfdc_auth_hash["passwordandtoken"], 13 | client_id: sfdc_auth_hash["client_id"], 14 | client_secret: sfdc_auth_hash["client_secret"], 15 | host: sfdc_auth_hash["host"] 16 | ) 17 | @sf_client.authenticate! 18 | 19 | @account_id = auth_hash["salesforce"]["test_account_id"] 20 | 21 | @api = SalesforceBulkApi::Api.new(@sf_client) 22 | end 23 | 24 | after :each do 25 | end 26 | 27 | describe "upsert" do 28 | context "when not passed get_result" do 29 | it "doesn't return the batches array" do 30 | res = @api.upsert("Account", [{Id: @account_id, Website: "www.test.com"}], "Id") 31 | res["batches"].should be_nil 32 | end 33 | end 34 | 35 | context "when passed get_result = true" do 36 | it "returns the batches array" do 37 | res = @api.upsert("Account", [{Id: @account_id, Website: "www.test.com"}], "Id", true) 38 | res["batches"][0]["response"].is_a? Array 39 | 40 | res["batches"][0]["response"][0]["id"][0].should start_with(@account_id) 41 | res["batches"][0]["response"][0]["success"].should eq ["true"] 42 | res["batches"][0]["response"][0]["created"].should eq ["false"] 43 | end 44 | end 45 | 46 | context "when passed send_nulls = true" do 47 | it "sets the nil and empty attributes to NULL" do 48 | @api.update("Account", [{Id: @account_id, Website: "abc123", Phone: "5678"}], true) 49 | res = @api.query("Account", "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'") 50 | res["batches"][0]["response"][0]["Website"][0].should eq "abc123" 51 | res["batches"][0]["response"][0]["Phone"][0].should eq "5678" 52 | res = @api.upsert("Account", [{Id: @account_id, Website: "", Phone: nil}], "Id", true, true) 53 | res["batches"][0]["response"][0]["id"][0].should start_with(@account_id) 54 | res["batches"][0]["response"][0]["success"].should eq ["true"] 55 | res["batches"][0]["response"][0]["created"].should eq ["false"] 56 | res = @api.query("Account", "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'") 57 | res["batches"][0]["response"][0]["Website"][0].should eq({"xsi:nil" => "true"}) 58 | res["batches"][0]["response"][0]["Phone"][0].should eq({"xsi:nil" => "true"}) 59 | end 60 | end 61 | 62 | context "when passed send_nulls = true and an array of fields not to null" do 63 | it "sets the nil and empty attributes to NULL, except for those included in the list of fields to ignore" do 64 | @api.update("Account", [{Id: @account_id, Website: "abc123", Phone: "5678"}], true) 65 | res = @api.query("Account", "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'") 66 | res["batches"][0]["response"][0]["Website"][0].should eq "abc123" 67 | res["batches"][0]["response"][0]["Phone"][0].should eq "5678" 68 | res = @api.upsert("Account", [{Id: @account_id, Website: "", Phone: nil}], "Id", true, true, [:Website, :Phone]) 69 | res["batches"][0]["response"][0]["id"][0].should start_with(@account_id) 70 | res["batches"][0]["response"][0]["success"].should eq ["true"] 71 | res["batches"][0]["response"][0]["created"].should eq ["false"] 72 | res = @api.query("Account", "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'") 73 | res["batches"][0]["response"][0]["Website"][0].should eq("abc123") 74 | res["batches"][0]["response"][0]["Phone"][0].should eq("5678") 75 | end 76 | end 77 | end 78 | 79 | describe "update" do 80 | context "when there is not an error" do 81 | context "when not passed get_result" do 82 | it "doesnt return the batches array" do 83 | res = @api.update("Account", [{Id: @account_id, Website: "www.test.com"}]) 84 | res["batches"].should be_nil 85 | end 86 | end 87 | 88 | context "when passed get_result = true" do 89 | it "returns the batches array" do 90 | res = @api.update("Account", [{Id: @account_id, Website: "www.test.com"}], true) 91 | res["batches"][0]["response"].is_a? Array 92 | res["batches"][0]["response"][0]["id"][0].should start_with(@account_id) 93 | res["batches"][0]["response"][0]["success"].should eq ["true"] 94 | res["batches"][0]["response"][0]["created"].should eq ["false"] 95 | end 96 | end 97 | end 98 | 99 | context "when there is an error" do 100 | context "when not passed get_result" do 101 | it "doesn't return the results array" do 102 | res = @api.update("Account", [{Id: @account_id, Website: "www.test.com"}, {Id: "abc123", Website: "www.test.com"}]) 103 | res["batches"].should be_nil 104 | end 105 | end 106 | 107 | context "when passed get_result = true with batches" do 108 | it "returns the results array" do 109 | 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) 110 | 111 | res["batches"][0]["response"][0]["id"][0].should start_with(@account_id) 112 | res["batches"][0]["response"][0]["success"].should eq ["true"] 113 | res["batches"][0]["response"][0]["created"].should eq ["false"] 114 | res["batches"][0]["response"][1]["id"][0].should start_with(@account_id) 115 | res["batches"][0]["response"][1]["success"].should eq ["true"] 116 | res["batches"][0]["response"][1]["created"].should eq ["false"] 117 | 118 | res["batches"][1]["response"][0]["id"][0].should start_with(@account_id) 119 | res["batches"][1]["response"][0]["success"].should eq ["true"] 120 | res["batches"][1]["response"][0]["created"].should eq ["false"] 121 | 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"]}) 122 | end 123 | end 124 | end 125 | end 126 | 127 | describe "create" do 128 | pending 129 | end 130 | 131 | describe "delete" do 132 | pending 133 | end 134 | 135 | describe "query" do 136 | context "when there are results" do 137 | it "returns the query results" do 138 | res = @api.query("Account", "SELECT id, Name From Account WHERE Name LIKE 'Test%'") 139 | res["batches"][0]["response"].length.should > 1 140 | res["batches"][0]["response"][0]["Id"].should_not be_nil 141 | end 142 | 143 | context "and there are multiple batches" do 144 | # need dev to create > 10k records in dev organization 145 | it "returns the query results in a merged hash" 146 | end 147 | end 148 | 149 | context "when there are no results" do 150 | it "returns nil" do 151 | res = @api.query("Account", "SELECT id From Account WHERE Name = 'ABC'") 152 | res["batches"][0]["response"].should eq nil 153 | end 154 | end 155 | 156 | context "when there is an error" do 157 | it "returns nil" do 158 | res = @api.query("Account", "SELECT id From Account WHERE Name = ''ABC'") 159 | res["batches"][0]["response"].should eq nil 160 | end 161 | end 162 | end 163 | 164 | describe "counters" do 165 | context "when read operations are called" do 166 | it "increments operation count and http GET count" do 167 | @api.counters[:http_get].should eq 0 168 | @api.counters[:query].should eq 0 169 | @api.query("Account", "SELECT Website, Phone From Account WHERE Id = '#{@account_id}'") 170 | @api.counters[:http_get].should eq 1 171 | @api.counters[:query].should eq 1 172 | end 173 | end 174 | 175 | context "when update operations are called" do 176 | it "increments operation count and http POST count" do 177 | @api.counters[:http_post].should eq 0 178 | @api.counters[:update].should eq 0 179 | @api.update("Account", [{Id: @account_id, Website: "abc123", Phone: "5678"}], true) 180 | @api.counters[:http_post].should eq 1 181 | @api.counters[:update].should eq 1 182 | end 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "salesforce_bulk_api" 4 | 5 | RSpec.configure do |c| 6 | c.filter_run focus: true 7 | c.run_all_when_everything_filtered = true 8 | end 9 | --------------------------------------------------------------------------------