active_force 0.18.0 → 0.20.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +9 -0
- data/README.md +20 -3
- data/lib/active_force/bulk/job.rb +136 -0
- data/lib/active_force/bulk/job_result.rb +48 -0
- data/lib/active_force/bulk/records.rb +42 -0
- data/lib/active_force/bulk.rb +47 -0
- data/lib/active_force/query.rb +12 -4
- data/lib/active_force/sobject.rb +2 -0
- data/lib/active_force/version.rb +1 -1
- data/lib/active_force.rb +1 -0
- data/spec/active_force/bulk/job_result_spec.rb +116 -0
- data/spec/active_force/bulk/job_spec.rb +113 -0
- data/spec/active_force/bulk/record_spec.rb +54 -0
- data/spec/active_force/bulk_spec.rb +110 -0
- data/spec/active_force/query_spec.rb +16 -0
- metadata +14 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1647fdc453090811c384514ae857c83b686dc966e2af5e47af3a6dc67e42059d
|
4
|
+
data.tar.gz: 515a98a5715c24359739d76730758b728a9cbf1b8c0cbf2b3c1bb5a355a21c9e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 48115b7a0df0201a24f477ba2f658e692824337a78c0d288afd3c056b42fccaaf065e1e3ab296fa16bda1f2f2eaf82ff1322adcea6d9d6a9f82c2318cea15d98
|
7
|
+
data.tar.gz: a7b6dacca6775374b089cc9022462c470d8103d63792c7e7668ccd3e082446b0429a2d54d0e3392694c0e25ea25139916d11bc9f0080659faea6e7eaa0e41541
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,15 @@
|
|
2
2
|
|
3
3
|
## Not released
|
4
4
|
|
5
|
+
## 0.20.0
|
6
|
+
|
7
|
+
- Change `.first` to not query the API if records have already been retrieved (https://github.com/Beyond-Finance/active_force/pull/73)
|
8
|
+
- Bugfix: Transform NULL values for SF Bulk API, which expects "#N/A" (https://github.com/Beyond-Finance/active_force/pull/74)
|
9
|
+
|
10
|
+
## 0.19.0
|
11
|
+
|
12
|
+
- Bulk API methods. (https://github.com/Beyond-Finance/active_force/pull/65)
|
13
|
+
|
5
14
|
## 0.18.0
|
6
15
|
|
7
16
|
- Fix eager loading of scoped associations. (https://github.com/Beyond-Finance/active_force/pull/67)
|
data/README.md
CHANGED
@@ -194,7 +194,7 @@ Comment.includes(:post)
|
|
194
194
|
|
195
195
|
It is possible to eager load multi level associations
|
196
196
|
|
197
|
-
In order to utilize multi level eager loads, the API version should be set to 58.0 or higher when instantiating a Restforce client
|
197
|
+
In order to utilize multi level eager loads, the API version should be set to 58.0 or higher when instantiating a Restforce client
|
198
198
|
|
199
199
|
```ruby
|
200
200
|
Restforce.new({api_version: '58.0'})
|
@@ -212,8 +212,8 @@ Comment.includes({post: {owner: :account}})
|
|
212
212
|
Summing the values of a column:
|
213
213
|
```ruby
|
214
214
|
Transaction.where(offer_id: 'ABD832024').sum(:amount)
|
215
|
-
#=> This will query "SELECT SUM(Amount__c)
|
216
|
-
# FROM Transaction__c
|
215
|
+
#=> This will query "SELECT SUM(Amount__c)
|
216
|
+
# FROM Transaction__c
|
217
217
|
# WHERE offer_id = 'ABD832024'"
|
218
218
|
```
|
219
219
|
|
@@ -249,6 +249,22 @@ accounts = Account.where(web_enabled: 1).limit(2)
|
|
249
249
|
with data from another API, and will only query the other API once.
|
250
250
|
```
|
251
251
|
|
252
|
+
### Bulk Jobs
|
253
|
+
|
254
|
+
For more information about usage and limits of the Salesforce Bulk API see the [docs][4].
|
255
|
+
|
256
|
+
Convenience class methods have been added to `ActiveForce::SObject` to make it possible to utilize the Salesforce Bulk API v2.0.
|
257
|
+
The methods are: `bulk_insert_all`, `bulk_update_all`, & `bulk_delete_all`. They all expect input as an Array of attributes as a Hash:
|
258
|
+
```ruby
|
259
|
+
[
|
260
|
+
{ id: '11111111', attribute1: 'value1', attribute2: 'value2'},
|
261
|
+
{ id: '22222222', attribute1: 'value3', attribute2: 'value4'},
|
262
|
+
]
|
263
|
+
```
|
264
|
+
The attributes will be mapped back to what's expected on the SF side automatically. The response is a `ActiveForce::Bulk::JobResult` object
|
265
|
+
which can access `successful` & `failed` results, has some `stats`, and the original `job` object that was used to create and process the
|
266
|
+
Bulk job.
|
267
|
+
|
252
268
|
### Model generator
|
253
269
|
|
254
270
|
When using rails, you can generate a model with all the fields you have on your SFDC table by running:
|
@@ -268,4 +284,5 @@ When using rails, you can generate a model with all the fields you have on your
|
|
268
284
|
[1]: http://www.salesforce.com
|
269
285
|
[2]: https://github.com/ejholmes/restforce
|
270
286
|
[3]: https://github.com/bkeepers/dotenv
|
287
|
+
[4]: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/bulk_api_2_0.htm
|
271
288
|
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'active_force/bulk/job_result'
|
2
|
+
|
3
|
+
module ActiveForce
|
4
|
+
module Bulk
|
5
|
+
class Job
|
6
|
+
attr_reader :operation, :records, :state, :object, :content_url
|
7
|
+
attr_accessor :id
|
8
|
+
|
9
|
+
STATES = {
|
10
|
+
Open: 'Open',
|
11
|
+
UploadComplete: 'UploadComplete',
|
12
|
+
InProgress: 'InProgress',
|
13
|
+
JobComplete: 'JobComplete',
|
14
|
+
Failed: 'Failed',
|
15
|
+
Aborted: 'Aborted',
|
16
|
+
Deleted: 'Deleted'
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
OPERATIONS = %i[insert delete hardDelete update upsert]
|
20
|
+
|
21
|
+
def initialize(operation:, object:, id: nil, records: nil)
|
22
|
+
@operation = operation.to_sym
|
23
|
+
@object = object
|
24
|
+
@id = id
|
25
|
+
@records = records
|
26
|
+
@state = nil
|
27
|
+
@content_url = nil
|
28
|
+
initialize_state_methods
|
29
|
+
end
|
30
|
+
|
31
|
+
def create
|
32
|
+
request_body = default_job_options.merge(operation: operation, object: object)
|
33
|
+
response = client.post("#{ingest_path}/", request_body)
|
34
|
+
update_attributes_from(response)
|
35
|
+
response
|
36
|
+
end
|
37
|
+
|
38
|
+
def upload
|
39
|
+
headers = {"Content-Type": 'text/csv'}
|
40
|
+
response = client.put(content_url, records.to_csv, headers)
|
41
|
+
response
|
42
|
+
end
|
43
|
+
|
44
|
+
def result
|
45
|
+
ActiveForce::Bulk::JobResult.new(job: self)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.run(...)
|
49
|
+
job = new(...)
|
50
|
+
job.create
|
51
|
+
job.upload
|
52
|
+
job.run
|
53
|
+
job
|
54
|
+
end
|
55
|
+
|
56
|
+
def failed_results
|
57
|
+
client.get("#{ingest_path}/#{id}/failedResults/")
|
58
|
+
end
|
59
|
+
|
60
|
+
def successful_results
|
61
|
+
client.get("#{ingest_path}/#{id}/successfulResults/")
|
62
|
+
end
|
63
|
+
|
64
|
+
def info
|
65
|
+
response = client.get("#{ingest_path}/#{id}")
|
66
|
+
update_attributes_from(response)
|
67
|
+
response
|
68
|
+
end
|
69
|
+
|
70
|
+
def run
|
71
|
+
change_state(STATES[:UploadComplete])
|
72
|
+
end
|
73
|
+
|
74
|
+
def abort
|
75
|
+
change_state(STATES[:Aborted])
|
76
|
+
end
|
77
|
+
|
78
|
+
def delete
|
79
|
+
response = client.delete("#{ingest_path}/#{id}")
|
80
|
+
response
|
81
|
+
end
|
82
|
+
|
83
|
+
def finished?
|
84
|
+
job_complete? || failed? || aborted?
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
attr_writer :state, :object, :operation, :content_url
|
89
|
+
|
90
|
+
|
91
|
+
def ingest_path
|
92
|
+
"/services/data/v#{client.options[:api_version]}/jobs/ingest"
|
93
|
+
end
|
94
|
+
|
95
|
+
def client
|
96
|
+
@client ||= ActiveForce.sfdc_client
|
97
|
+
end
|
98
|
+
|
99
|
+
def default_job_options
|
100
|
+
{
|
101
|
+
columnDelimiter: 'COMMA',
|
102
|
+
contentType: 'CSV',
|
103
|
+
lineEnding: 'LF',
|
104
|
+
}
|
105
|
+
end
|
106
|
+
|
107
|
+
def change_state(value)
|
108
|
+
request_body = {state: value}
|
109
|
+
headers = {"Content-Type": "application/json"}
|
110
|
+
response = client.patch("#{ingest_path}/#{id}", request_body, headers)
|
111
|
+
update_attributes_from(response)
|
112
|
+
response
|
113
|
+
end
|
114
|
+
|
115
|
+
def update_attributes_from(response)
|
116
|
+
return unless response.body.present?
|
117
|
+
|
118
|
+
%i[id state object operation contentUrl].each do |attr|
|
119
|
+
self.send("#{attr.to_s.underscore}=", response.body[attr.to_s]) if response.body[attr.to_s].present?
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Defines question methods for various states of the job, e.g. #open?, #in_progress?, etc.
|
124
|
+
def initialize_state_methods
|
125
|
+
STATES.values.each do |state|
|
126
|
+
state_method = <<-STATE_METHOD
|
127
|
+
def #{state.to_s.underscore}?
|
128
|
+
@state == '#{state}'
|
129
|
+
end
|
130
|
+
STATE_METHOD
|
131
|
+
self.class_eval(state_method)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module ActiveForce
|
2
|
+
module Bulk
|
3
|
+
class JobResult
|
4
|
+
attr_reader :job, :failed, :successful, :stats, :errors
|
5
|
+
|
6
|
+
def initialize(job:)
|
7
|
+
@job = job
|
8
|
+
@stats = result_from_job_info
|
9
|
+
@failed = failed_results
|
10
|
+
@successful = successful_results
|
11
|
+
@errors = errors_from_failed_results
|
12
|
+
end
|
13
|
+
|
14
|
+
def success?
|
15
|
+
failed.blank? && successful.present?
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
attr_writer :errors, :failed, :successful
|
20
|
+
|
21
|
+
def errors_from_failed_results
|
22
|
+
return [] if @stats[:number_records_failed].zero? || self.failed.blank?
|
23
|
+
|
24
|
+
self.errors = self.failed.pluck('sf__Error').uniq
|
25
|
+
end
|
26
|
+
|
27
|
+
def failed_results
|
28
|
+
return [] if @stats[:number_records_failed].zero?
|
29
|
+
|
30
|
+
response = job.failed_results
|
31
|
+
self.failed = CSV.parse(response.body, headers: true).map(&:to_h)
|
32
|
+
end
|
33
|
+
|
34
|
+
def successful_results
|
35
|
+
response = job.successful_results
|
36
|
+
self.successful = CSV.parse(response.body, headers: true).map(&:to_h)
|
37
|
+
end
|
38
|
+
|
39
|
+
def job_info
|
40
|
+
job.info
|
41
|
+
end
|
42
|
+
|
43
|
+
def result_from_job_info
|
44
|
+
job_info&.body.slice('numberRecordsProcessed', 'numberRecordsFailed', 'totalProcessingTime').transform_keys { |k| k.to_s.underscore.to_sym }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'csv'
|
2
|
+
|
3
|
+
module ActiveForce
|
4
|
+
module Bulk
|
5
|
+
class Records
|
6
|
+
NULL_VALUE = '#N/A'.freeze
|
7
|
+
|
8
|
+
attr_reader :headers, :data
|
9
|
+
def initialize(headers:, data:)
|
10
|
+
@headers = headers
|
11
|
+
@data = data
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_csv
|
15
|
+
CSV.generate(String.new, headers: headers, write_headers: true) do |csv|
|
16
|
+
data.each { |row| csv << row }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.parse_from_attributes(records)
|
21
|
+
# Sorting ensures that the headers line up with the values for the CSV
|
22
|
+
headers = records.first.keys.sort.map(&:to_s)
|
23
|
+
data = records.map do |r|
|
24
|
+
r.transform_values { |v| transform_value_for_sf(v) }.sort.pluck(-1)
|
25
|
+
end
|
26
|
+
new(headers: headers, data: data)
|
27
|
+
end
|
28
|
+
|
29
|
+
# SF expects a special value for setting a column to be NULL.
|
30
|
+
def self.transform_value_for_sf(value)
|
31
|
+
case value
|
32
|
+
when NilClass
|
33
|
+
NULL_VALUE
|
34
|
+
when Time
|
35
|
+
value.iso8601
|
36
|
+
else
|
37
|
+
value.to_s
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'active_force/bulk/job'
|
2
|
+
require 'active_force/bulk/records'
|
3
|
+
|
4
|
+
module ActiveForce
|
5
|
+
module Bulk
|
6
|
+
class TimeoutError < Timeout::Error; end
|
7
|
+
TIMEOUT_MESSAGE = 'Bulk job execution expired based on timeout of %{timeout} seconds'.freeze
|
8
|
+
|
9
|
+
def bulk_insert_all(attributes, options={})
|
10
|
+
run_bulk_job(:insert, attributes, options)
|
11
|
+
end
|
12
|
+
|
13
|
+
def bulk_update_all(attributes, options={})
|
14
|
+
run_bulk_job(:update, attributes, options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def bulk_delete_all(attributes, options={})
|
18
|
+
run_bulk_job(:delete, attributes, options)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def default_options
|
24
|
+
{
|
25
|
+
timeout: 30,
|
26
|
+
sleep: 0.02 # short sleep so we can end our poll loop more quickly
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def run_bulk_job(operation, attributes, options)
|
31
|
+
runtime_options = default_options.merge(options)
|
32
|
+
records = Records.parse_from_attributes(translate_to_sf(attributes))
|
33
|
+
job = Job.run(operation: operation, object: self.table_name, records: records)
|
34
|
+
Timeout.timeout(runtime_options[:timeout], ActiveForce::Bulk::TimeoutError, TIMEOUT_MESSAGE % runtime_options) do
|
35
|
+
until job.finished? do
|
36
|
+
job.info
|
37
|
+
sleep(runtime_options[:sleep])
|
38
|
+
end
|
39
|
+
end
|
40
|
+
job.result
|
41
|
+
end
|
42
|
+
|
43
|
+
def translate_to_sf(attributes)
|
44
|
+
attributes.map{ |r| self.mapping.translate_to_sf(r) }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/active_force/query.rb
CHANGED
@@ -78,7 +78,15 @@ module ActiveForce
|
|
78
78
|
end
|
79
79
|
|
80
80
|
def first
|
81
|
-
|
81
|
+
if @records
|
82
|
+
clone_and_set_instance_variables(
|
83
|
+
size: 1,
|
84
|
+
records: [@records.first],
|
85
|
+
decorated_records: [@decorated_records&.first]
|
86
|
+
)
|
87
|
+
else
|
88
|
+
limit(1)
|
89
|
+
end
|
82
90
|
end
|
83
91
|
|
84
92
|
def last(limit = 1)
|
@@ -126,9 +134,9 @@ module ActiveForce
|
|
126
134
|
|
127
135
|
def clone_and_set_instance_variables instance_variable_hash={}
|
128
136
|
clone = self.clone
|
129
|
-
|
130
|
-
|
131
|
-
|
137
|
+
{ decorated_records: nil, records: nil }
|
138
|
+
.merge(instance_variable_hash)
|
139
|
+
.each { |k,v| clone.instance_variable_set("@#{k.to_s}", v) }
|
132
140
|
clone
|
133
141
|
end
|
134
142
|
end
|
data/lib/active_force/sobject.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'active_model'
|
2
2
|
require 'active_force/active_query'
|
3
3
|
require 'active_force/association'
|
4
|
+
require 'active_force/bulk'
|
4
5
|
require 'active_force/mapping'
|
5
6
|
require 'yaml'
|
6
7
|
require 'forwardable'
|
@@ -19,6 +20,7 @@ module ActiveForce
|
|
19
20
|
extend ActiveModel::Callbacks
|
20
21
|
include ActiveModel::Serializers::JSON
|
21
22
|
extend ActiveForce::Association
|
23
|
+
extend ActiveForce::Bulk
|
22
24
|
|
23
25
|
|
24
26
|
define_model_callbacks :build, :create, :update, :save, :destroy
|
data/lib/active_force/version.rb
CHANGED
data/lib/active_force.rb
CHANGED
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ActiveForce::Bulk::JobResult do
|
4
|
+
subject { described_class.new(job: job)}
|
5
|
+
let(:job) { instance_double(ActiveForce::Bulk::Job)}
|
6
|
+
|
7
|
+
let(:failed_results_response) do
|
8
|
+
Faraday::Response.new(status: 200, response_body: "\"sf__Id\",\"sf__Error\"\n\"45678\",\"Something failed.\"")
|
9
|
+
end
|
10
|
+
|
11
|
+
let(:successful_results_response) do
|
12
|
+
Faraday::Response.new(status: 200, response_body: "\"sf__Id\",\"sf__Error\"\n\"12354\",\"false\"\n")
|
13
|
+
end
|
14
|
+
|
15
|
+
let(:successful_info) do
|
16
|
+
{"id"=>"22222222222222",
|
17
|
+
"operation"=>"update",
|
18
|
+
"object"=>"FooBar__c",
|
19
|
+
"createdById"=>"11111111111111111",
|
20
|
+
"createdDate"=>"2023-09-22T16:39:09.000+0000",
|
21
|
+
"systemModstamp"=>"2023-09-22T16:39:13.000+0000",
|
22
|
+
"state"=>"JobComplete",
|
23
|
+
"concurrencyMode"=>"Parallel",
|
24
|
+
"contentType"=>"CSV",
|
25
|
+
"apiVersion"=>58.0,
|
26
|
+
"jobType"=>"V2Ingest",
|
27
|
+
"lineEnding"=>"LF",
|
28
|
+
"columnDelimiter"=>"COMMA",
|
29
|
+
"numberRecordsProcessed"=>1,
|
30
|
+
"numberRecordsFailed"=>0,
|
31
|
+
"retries"=>0,
|
32
|
+
"totalProcessingTime"=>713,
|
33
|
+
"apiActiveProcessingTime"=>323,
|
34
|
+
"apexProcessingTime"=>3}
|
35
|
+
end
|
36
|
+
|
37
|
+
let(:failed_info) do
|
38
|
+
{"id"=>"33333333333333",
|
39
|
+
"operation"=>"update",
|
40
|
+
"object"=>"FooBar__c",
|
41
|
+
"createdById"=>"11111111111111111",
|
42
|
+
"createdDate"=>"2023-09-22T16:39:09.000+0000",
|
43
|
+
"systemModstamp"=>"2023-09-22T16:39:13.000+0000",
|
44
|
+
"state"=>"JobComplete",
|
45
|
+
"concurrencyMode"=>"Parallel",
|
46
|
+
"contentType"=>"CSV",
|
47
|
+
"apiVersion"=>58.0,
|
48
|
+
"jobType"=>"V2Ingest",
|
49
|
+
"lineEnding"=>"LF",
|
50
|
+
"columnDelimiter"=>"COMMA",
|
51
|
+
"numberRecordsProcessed"=>1,
|
52
|
+
"numberRecordsFailed"=>1,
|
53
|
+
"retries"=>0,
|
54
|
+
"totalProcessingTime"=>713,
|
55
|
+
"apiActiveProcessingTime"=>323,
|
56
|
+
"apexProcessingTime"=>3}
|
57
|
+
end
|
58
|
+
|
59
|
+
let(:successful_job_info_response) do
|
60
|
+
Faraday::Response.new(status: 200, response_body: successful_info)
|
61
|
+
end
|
62
|
+
|
63
|
+
let(:failed_job_info_response) do
|
64
|
+
Faraday::Response.new(status: 200, response_body: failed_info)
|
65
|
+
end
|
66
|
+
|
67
|
+
before do
|
68
|
+
allow(job).to receive(:failed_results).and_return(failed_results_response)
|
69
|
+
allow(job).to receive(:successful_results).and_return(successful_results_response)
|
70
|
+
allow(job).to receive(:info).and_return(successful_job_info_response)
|
71
|
+
end
|
72
|
+
|
73
|
+
describe '::new' do
|
74
|
+
it 'uses the passed in job object to pull job info' do
|
75
|
+
expect(subject.stats[:total_processing_time]).to eq successful_info['totalProcessingTime']
|
76
|
+
expect(subject.stats[:number_records_processed]).to eq successful_info['numberRecordsProcessed']
|
77
|
+
expect(subject.stats[:number_records_failed]).to eq successful_info['numberRecordsFailed']
|
78
|
+
expect(subject.failed).to be_empty
|
79
|
+
expect(subject.successful.size).to eq 1
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
describe '#success?' do
|
84
|
+
context 'when no failed results' do
|
85
|
+
it 'returns true' do
|
86
|
+
expect(subject.success?).to be true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
context 'when there are failed results' do
|
90
|
+
before do
|
91
|
+
allow(job).to receive(:info).and_return(failed_job_info_response)
|
92
|
+
end
|
93
|
+
it 'returns false' do
|
94
|
+
expect(subject.success?).to be false
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe '#errors' do
|
100
|
+
context 'when there are failed results' do
|
101
|
+
before do
|
102
|
+
allow(job).to receive(:info).and_return(failed_job_info_response)
|
103
|
+
end
|
104
|
+
it 'returns an array of errors' do
|
105
|
+
expect(subject.errors).to eq ['Something failed.']
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context 'when there are no failed results' do
|
110
|
+
it 'returns an empty array' do
|
111
|
+
expect(subject.errors).to eq []
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ActiveForce::Bulk::Job do
|
4
|
+
subject { described_class.new(**args)}
|
5
|
+
let(:args) { { operation: operation, object: object, id: id, records: records } }
|
6
|
+
let(:operation) { described_class::OPERATIONS.sample }
|
7
|
+
let(:object) { 'Whizbang__c' }
|
8
|
+
let(:id) { nil }
|
9
|
+
let(:records) { instance_double(ActiveForce::Bulk::Records) }
|
10
|
+
let(:sfdc_client) { spy(ActiveForce.sfdc_client.class)}
|
11
|
+
let(:state) { 'Open' }
|
12
|
+
let(:api_version) { '58.0' }
|
13
|
+
let(:ingest_url) { "/services/data/v#{api_version}/jobs/ingest" }
|
14
|
+
|
15
|
+
before do
|
16
|
+
allow(ActiveForce).to receive(:sfdc_client).and_return(sfdc_client)
|
17
|
+
allow(sfdc_client).to receive(:options).and_return({api_version: api_version})
|
18
|
+
allow(records).to receive(:to_csv)
|
19
|
+
end
|
20
|
+
|
21
|
+
describe '#finished?' do
|
22
|
+
let(:finished_state) { described_class::STATES.slice(:JobComplete, :Failed, :Aborted).values.sample }
|
23
|
+
let(:unfinished_state) { described_class::STATES.except(:JobComplete, :Failed, :Aborted).values.sample }
|
24
|
+
|
25
|
+
context 'when job is in one of the finished states' do
|
26
|
+
it 'returns true' do
|
27
|
+
job = subject
|
28
|
+
job.instance_variable_set(:@state, finished_state)
|
29
|
+
expect(job.finished?).to be true
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
context 'when job is NOT in one of the finished states' do
|
34
|
+
it 'returns false' do
|
35
|
+
job = subject
|
36
|
+
job.instance_variable_set(:@state, unfinished_state)
|
37
|
+
expect(job.finished?).to be false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '::run' do
|
43
|
+
let(:job) { spy(described_class)}
|
44
|
+
|
45
|
+
before do
|
46
|
+
allow(described_class).to receive(:new).and_return(job)
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'creates, uploads, and runs the job via the SF API' do
|
50
|
+
described_class.run(**args)
|
51
|
+
expect(job).to have_received(:create)
|
52
|
+
expect(job).to have_received(:upload)
|
53
|
+
expect(job).to have_received(:run)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe '#run' do
|
58
|
+
it 'PATCHs the job state to SF API to run the job' do
|
59
|
+
subject.run
|
60
|
+
expect(sfdc_client).to have_received(:patch).with("#{ingest_url}/#{id}", {state: described_class::STATES[:UploadComplete]}, anything)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe '#abort' do
|
65
|
+
it 'PATCHs the job state to SF API to abort the job' do
|
66
|
+
subject.abort
|
67
|
+
expect(sfdc_client).to have_received(:patch).with("#{ingest_url}/#{id}", {state: described_class::STATES[:Aborted]}, anything)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
describe '#delete' do
|
72
|
+
it 'DELETEs the job from SF API' do
|
73
|
+
subject.delete
|
74
|
+
expect(sfdc_client).to have_received(:delete).with("#{ingest_url}/#{id}")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
describe '#failed_results' do
|
79
|
+
it 'GETs job info from SF API' do
|
80
|
+
subject.failed_results
|
81
|
+
expect(sfdc_client).to have_received(:get).with("#{ingest_url}/#{id}/failedResults/")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
describe '#successful_results' do
|
86
|
+
it 'GETs successful results from SF API' do
|
87
|
+
subject.successful_results
|
88
|
+
expect(sfdc_client).to have_received(:get).with("#{ingest_url}/#{id}/successfulResults/")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
describe '#info' do
|
93
|
+
it 'GETs job info from SF API' do
|
94
|
+
subject.info
|
95
|
+
expect(sfdc_client).to have_received(:get).with("#{ingest_url}/#{id}")
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe '#upload' do
|
100
|
+
it 'PUTs CSV to SF API to upload records to job' do
|
101
|
+
subject.upload
|
102
|
+
expect(sfdc_client).to have_received(:put).with(subject.content_url, anything, hash_including("Content-Type": 'text/csv'))
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe '#create' do
|
107
|
+
it 'POSTs to SF API to create a job' do
|
108
|
+
subject.create
|
109
|
+
expect(sfdc_client).to have_received(:post).with("#{ingest_url}/", hash_including(operation: operation, object: object))
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ActiveForce::Bulk::Records do
|
4
|
+
subject { described_class.new(headers: headers, data: data)}
|
5
|
+
let(:headers) { %w[header1 header2] }
|
6
|
+
let(:data) do
|
7
|
+
[
|
8
|
+
%w[value1 value2],
|
9
|
+
%w[value3 value4],
|
10
|
+
]
|
11
|
+
end
|
12
|
+
|
13
|
+
describe '#to_csv' do
|
14
|
+
it 'returns CSV with headers' do
|
15
|
+
expect(subject.to_csv).to eq "header1,header2\nvalue1,value2\nvalue3,value4\n"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '::parse_from_attributes' do
|
20
|
+
subject { described_class.parse_from_attributes(attributes) }
|
21
|
+
let(:attributes) do
|
22
|
+
[
|
23
|
+
{ header1: 'value1', header2: 'value2'},
|
24
|
+
{ header1: 'value3', header2: 'value4'},
|
25
|
+
]
|
26
|
+
end
|
27
|
+
it 'parses array of hash attributes into Records object with headers and data' do
|
28
|
+
records = subject
|
29
|
+
expect(records).to be_a described_class
|
30
|
+
expect(records.headers).to eq headers
|
31
|
+
expect(records.data).to eq data
|
32
|
+
end
|
33
|
+
|
34
|
+
context 'when there are NULL values' do
|
35
|
+
let(:attributes) do
|
36
|
+
[
|
37
|
+
{ header1: nil, header2: 'value2'},
|
38
|
+
{ header1: 'value3', header2: nil},
|
39
|
+
]
|
40
|
+
end
|
41
|
+
let(:data) do
|
42
|
+
[
|
43
|
+
%w[#N/A value2],
|
44
|
+
%w[value3 #N/A],
|
45
|
+
]
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'substitutes the expected SF value for NULL' do
|
49
|
+
records = subject
|
50
|
+
expect(records.data).to eq data
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
# Set up a new SObject to test the mixin.
|
4
|
+
class FooBarSObject < ActiveForce::SObject
|
5
|
+
field :id, from: 'Id'
|
6
|
+
field :baz_id, from: 'Baz_id__c'
|
7
|
+
end
|
8
|
+
|
9
|
+
describe ActiveForce::Bulk do
|
10
|
+
subject { FooBarSObject }
|
11
|
+
let(:attributes) do
|
12
|
+
[
|
13
|
+
{id: 1, baz_id: 1 },
|
14
|
+
{id: 2, baz_id: 1 },
|
15
|
+
{id: 3, baz_id: 2 },
|
16
|
+
]
|
17
|
+
end
|
18
|
+
let(:job) { double(ActiveForce::Bulk::Job) }
|
19
|
+
let(:timeout_message) { /Bulk job execution expired based on timeout of/ }
|
20
|
+
before do
|
21
|
+
allow(job).to receive(:result)
|
22
|
+
end
|
23
|
+
|
24
|
+
describe '::bulk_insert_all' do
|
25
|
+
before do
|
26
|
+
allow(ActiveForce::Bulk::Job).to receive(:run).with(hash_including(operation: :insert)).and_return(job)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'runs a bulk insert job' do
|
30
|
+
allow(job).to receive(:finished?).and_return(true)
|
31
|
+
subject.bulk_insert_all(attributes)
|
32
|
+
end
|
33
|
+
|
34
|
+
context 'when job takes a while to run' do
|
35
|
+
it 'polls job info until job is finished' do
|
36
|
+
allow(job).to receive(:finished?).and_return(false, false, true)
|
37
|
+
allow(job).to receive(:info)
|
38
|
+
subject.bulk_insert_all(attributes)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context 'when job run exceeds timeout' do
|
43
|
+
it 'raises error' do
|
44
|
+
allow(job).to receive(:finished?).and_return(false)
|
45
|
+
allow(job).to receive(:info)
|
46
|
+
expect do
|
47
|
+
subject.bulk_insert_all(attributes, timeout: 0.1)
|
48
|
+
end.to raise_error(ActiveForce::Bulk::TimeoutError, timeout_message)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe '::bulk_update_all' do
|
54
|
+
before do
|
55
|
+
allow(ActiveForce::Bulk::Job).to receive(:run).with(hash_including(operation: :update)).and_return(job)
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'runs a bulk insert job' do
|
59
|
+
allow(job).to receive(:finished?).and_return(true)
|
60
|
+
subject.bulk_update_all(attributes)
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'when job takes a while to run' do
|
64
|
+
it 'polls job info until job is finished' do
|
65
|
+
allow(job).to receive(:finished?).and_return(false, false, true)
|
66
|
+
allow(job).to receive(:info)
|
67
|
+
subject.bulk_update_all(attributes)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
context 'when job run exceeds timeout' do
|
72
|
+
it 'raises error' do
|
73
|
+
allow(job).to receive(:finished?).and_return(false)
|
74
|
+
allow(job).to receive(:info)
|
75
|
+
expect do
|
76
|
+
subject.bulk_update_all(attributes, timeout: 0.1)
|
77
|
+
end.to raise_error(ActiveForce::Bulk::TimeoutError, timeout_message)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe '::bulk_delete_all' do
|
83
|
+
before do
|
84
|
+
allow(ActiveForce::Bulk::Job).to receive(:run).with(hash_including(operation: :delete)).and_return(job)
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'runs a bulk insert job' do
|
88
|
+
allow(job).to receive(:finished?).and_return(true)
|
89
|
+
subject.bulk_delete_all(attributes)
|
90
|
+
end
|
91
|
+
|
92
|
+
context 'when job takes a while to run' do
|
93
|
+
it 'polls job info until job is finished' do
|
94
|
+
allow(job).to receive(:finished?).and_return(false, false, true)
|
95
|
+
allow(job).to receive(:info)
|
96
|
+
subject.bulk_delete_all(attributes)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
context 'when job run exceeds timeout' do
|
101
|
+
it 'raises error' do
|
102
|
+
allow(job).to receive(:finished?).and_return(false)
|
103
|
+
allow(job).to receive(:info)
|
104
|
+
expect do
|
105
|
+
subject.bulk_delete_all(attributes, timeout: 0.1)
|
106
|
+
end.to raise_error(ActiveForce::Bulk::TimeoutError, timeout_message)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -165,6 +165,22 @@ describe ActiveForce::Query do
|
|
165
165
|
expect(query.to_s).to eq "SELECT Id, name, etc FROM table_name"
|
166
166
|
expect(new_query.to_s).to eq 'SELECT Id, name, etc FROM table_name LIMIT 1'
|
167
167
|
end
|
168
|
+
|
169
|
+
it "does not query if records have already been fetched" do
|
170
|
+
query = ActiveForce::Query.new 'table_name'
|
171
|
+
query.instance_variable_set(:@records, %w[foo bar])
|
172
|
+
query.instance_variable_set(:@decorated_records, %w[foo bar])
|
173
|
+
expect(query).not_to receive(:limit)
|
174
|
+
expect(query).to receive(:clone_and_set_instance_variables).with(size: 1, records: ['foo'], decorated_records: ['foo'])
|
175
|
+
query.first
|
176
|
+
end
|
177
|
+
|
178
|
+
it 'queries the api if it has not been queried yet' do
|
179
|
+
query = ActiveForce::Query.new 'table_name'
|
180
|
+
query.instance_variable_set(:@records, nil)
|
181
|
+
expect(query).to receive(:limit)
|
182
|
+
query.first
|
183
|
+
end
|
168
184
|
end
|
169
185
|
|
170
186
|
describe '.last' do
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_force
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.20.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Eloy Espinaco
|
@@ -11,7 +11,7 @@ authors:
|
|
11
11
|
autorequire:
|
12
12
|
bindir: bin
|
13
13
|
cert_chain: []
|
14
|
-
date: 2023-
|
14
|
+
date: 2023-11-27 00:00:00.000000000 Z
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
17
17
|
name: activemodel
|
@@ -155,6 +155,10 @@ files:
|
|
155
155
|
- lib/active_force/association/has_many_association.rb
|
156
156
|
- lib/active_force/association/has_one_association.rb
|
157
157
|
- lib/active_force/association/relation_model_builder.rb
|
158
|
+
- lib/active_force/bulk.rb
|
159
|
+
- lib/active_force/bulk/job.rb
|
160
|
+
- lib/active_force/bulk/job_result.rb
|
161
|
+
- lib/active_force/bulk/records.rb
|
158
162
|
- lib/active_force/field.rb
|
159
163
|
- lib/active_force/mapping.rb
|
160
164
|
- lib/active_force/query.rb
|
@@ -170,6 +174,10 @@ files:
|
|
170
174
|
- spec/active_force/active_query_spec.rb
|
171
175
|
- spec/active_force/association/relation_model_builder_spec.rb
|
172
176
|
- spec/active_force/association_spec.rb
|
177
|
+
- spec/active_force/bulk/job_result_spec.rb
|
178
|
+
- spec/active_force/bulk/job_spec.rb
|
179
|
+
- spec/active_force/bulk/record_spec.rb
|
180
|
+
- spec/active_force/bulk_spec.rb
|
173
181
|
- spec/active_force/callbacks_spec.rb
|
174
182
|
- spec/active_force/field_spec.rb
|
175
183
|
- spec/active_force/mapping_spec.rb
|
@@ -216,6 +224,10 @@ test_files:
|
|
216
224
|
- spec/active_force/active_query_spec.rb
|
217
225
|
- spec/active_force/association/relation_model_builder_spec.rb
|
218
226
|
- spec/active_force/association_spec.rb
|
227
|
+
- spec/active_force/bulk/job_result_spec.rb
|
228
|
+
- spec/active_force/bulk/job_spec.rb
|
229
|
+
- spec/active_force/bulk/record_spec.rb
|
230
|
+
- spec/active_force/bulk_spec.rb
|
219
231
|
- spec/active_force/callbacks_spec.rb
|
220
232
|
- spec/active_force/field_spec.rb
|
221
233
|
- spec/active_force/mapping_spec.rb
|