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