active_force 0.17.0 → 0.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 741f33a6c27d6bc11eaaa7655c2a7fb2ecc772f93d298e608866840b82e94321
4
- data.tar.gz: 465abe2c572e35dc61817ab1bf8ac1d6d0e35e6f26eb2e933af3ed1781c2c68a
3
+ metadata.gz: 38ef881c292f8ecb12be07a0471f759a06b9155af5e3a49c26dbb69e024db87f
4
+ data.tar.gz: 6a209feb04dc0c6a192c084a95c2e55c64696bbb54636496fc98dc8c56c7b653
5
5
  SHA512:
6
- metadata.gz: 376ab891dc3a0bf92f030dd3b756aa900c8091bfd42b3424ef1c832ecc2ac14384785f96eeadc20764f22ee02315753cba10c4b0aec067294e2175a09754dc60
7
- data.tar.gz: f60cf65275aaee5e96ebb173cc0718cbc663b3cced951bccf8b417df1029055606be56bdf82617e689130d68687b8ce8f8227acab83314c54715e95e6ca28629
6
+ metadata.gz: 814f0f4844f94acea113a4eabf1e3afd366b2b00255fe5bcca5ccfccb0f5d862651548302373d078a7c070422d6db6c8cf971f33112c360f0d43f133d500c98a
7
+ data.tar.gz: 48f595e34749ec49adeaeb8320c407911f8841d83fcd433cdd5e7e9bc5b6b8f37d01a0f30f6a96610d91949ab8e30e2cb4b0c3f9aa18692447fc31ed6d4b75fb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Not released
4
4
 
5
+ ## 0.19.0
6
+
7
+ - Bulk API methods. (https://github.com/Beyond-Finance/active_force/pull/65)
8
+
9
+ ## 0.18.0
10
+
11
+ - Fix eager loading of scoped associations. (https://github.com/Beyond-Finance/active_force/pull/67)
12
+ - Adding `.blank?`, `.present?`, and `.any?` delegators to `ActiveQuery`. (https://github.com/Beyond-Finance/active_force/pull/68)
13
+ - Adding `update` and `update!` class methods on `SObject`. (https://github.com/Beyond-Finance/active_force/pull/66)
14
+ - Allow an argument to `last` allowing the query to select the `last(n)` records. Default is 1. (https://github.com/Beyond-Finance/active_force/pull/66)
15
+
5
16
  ## 0.17.0
6
17
 
7
18
  - Fix bug with has_many queries due to query method chaining mutating in-place (https://github.com/Beyond-Finance/active_force/pull/10)
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
 
@@ -21,9 +21,9 @@ module ActiveForce
21
21
  attr_reader :sobject, :association_mapping, :belongs_to_association_mapping
22
22
 
23
23
  def_delegators :sobject, :sfdc_client, :build, :table_name, :mappings
24
- def_delegators :to_a, :each, :map, :inspect, :pluck, :each_with_object
24
+ def_delegators :to_a, :blank?, :present?, :any?, :each, :map, :inspect, :pluck, :each_with_object
25
25
 
26
- def initialize (sobject, custom_table_name = nil)
26
+ def initialize(sobject, custom_table_name = nil)
27
27
  @sobject = sobject
28
28
  @association_mapping = {}
29
29
  @belongs_to_association_mapping = {}
@@ -26,6 +26,14 @@ module ActiveForce
26
26
  options[:relationship_name] || relation_model.to_s.constantize.table_name
27
27
  end
28
28
 
29
+ def scoped_as
30
+ options[:scoped_as] || nil
31
+ end
32
+
33
+ def scoped?
34
+ options[:scoped_as].present?
35
+ end
36
+
29
37
  ###
30
38
  # Does this association's relation_model represent
31
39
  # +sfdc_table_name+? Examples of +sfdc_table_name+
@@ -1,5 +1,6 @@
1
1
  module ActiveForce
2
2
  module Association
3
+ class InvalidEagerLoadAssociation < StandardError; end
3
4
  class EagerLoadProjectionBuilder
4
5
  class << self
5
6
  def build(association, parent_association_field = nil)
@@ -34,6 +35,13 @@ module ActiveForce
34
35
  def projections
35
36
  raise "Must define #{self.class.name}#projections"
36
37
  end
38
+
39
+ def apply_association_scope(query)
40
+ return query unless association.scoped?
41
+ raise InvalidEagerLoadAssociation, "Cannot use scopes that expect arguments: #{association.relation_name}" if association.scoped_as.arity.positive?
42
+
43
+ query.instance_exec(&association.scoped_as)
44
+ end
37
45
  end
38
46
 
39
47
  class HasManyAssociationProjectionBuilder < AbstractProjectionBuilder
@@ -43,17 +51,17 @@ module ActiveForce
43
51
  # to be pluralized
44
52
  def projections
45
53
  relationship_name = association.sfdc_association_field
46
- query = Query.new relationship_name
54
+ query = ActiveQuery.new(association.relation_model, relationship_name)
47
55
  query.fields association.relation_model.fields
48
- ["(#{query.to_s})"]
56
+ ["(#{apply_association_scope(query).to_s})"]
49
57
  end
50
58
  end
51
59
 
52
60
  class HasOneAssociationProjectionBuilder < AbstractProjectionBuilder
53
61
  def projections
54
- query = Query.new association.sfdc_association_field
62
+ query = ActiveQuery.new(association.relation_model, association.sfdc_association_field)
55
63
  query.fields association.relation_model.fields
56
- ["(#{query.to_s})"]
64
+ ["(#{apply_association_scope(query).to_s})"]
57
65
  end
58
66
  end
59
67
 
@@ -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,25 @@
1
+ require 'csv'
2
+
3
+ module ActiveForce
4
+ module Bulk
5
+ class Records
6
+ attr_reader :headers, :data
7
+ def initialize(headers:, data:)
8
+ @headers = headers
9
+ @data = data
10
+ end
11
+
12
+ def to_csv
13
+ CSV.generate(String.new, headers: headers, write_headers: true) do |csv|
14
+ data.each { |row| csv << row }
15
+ end
16
+ end
17
+
18
+ def self.parse_from_attributes(records)
19
+ headers = records.first.keys.sort.map(&:to_s)
20
+ data = records.map { |r| r.sort.pluck(-1) }
21
+ new(headers: headers, data: data)
22
+ end
23
+ end
24
+ end
25
+ 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
@@ -81,8 +81,8 @@ module ActiveForce
81
81
  limit 1
82
82
  end
83
83
 
84
- def last
85
- order("Id DESC").limit(1)
84
+ def last(limit = 1)
85
+ order("Id DESC").limit(limit)
86
86
  end
87
87
 
88
88
  def join object_query
@@ -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
@@ -129,6 +131,14 @@ module ActiveForce
129
131
  new(args).create!
130
132
  end
131
133
 
134
+ def self.update(id, attributes)
135
+ new(attributes.merge(id: id)).update
136
+ end
137
+
138
+ def self.update!(id, attributes)
139
+ new(attributes.merge(id: id)).update!
140
+ end
141
+
132
142
  def save!
133
143
  run_callbacks :save do
134
144
  if persisted?
@@ -231,7 +241,7 @@ module ActiveForce
231
241
  end
232
242
 
233
243
  def default_attributes
234
- @attributes.each_value.select do |value|
244
+ @attributes.each_value.select do |value|
235
245
  value.is_a?(ActiveModel::Attribute::UserProvidedDefault) || value.instance_values["original_attribute"].is_a?(ActiveModel::Attribute::UserProvidedDefault)
236
246
  end.map(&:name)
237
247
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveForce
4
- VERSION = '0.17.0'
4
+ VERSION = '0.19.0'
5
5
  end
data/lib/active_force.rb CHANGED
@@ -3,6 +3,7 @@ require 'active_model/type/salesforce/percent'
3
3
  require 'active_force/version'
4
4
  require 'active_force/sobject'
5
5
  require 'active_force/query'
6
+ require 'active_force/bulk'
6
7
 
7
8
  module ActiveForce
8
9
 
@@ -18,7 +18,6 @@ describe ActiveForce::ActiveQuery do
18
18
  ]
19
19
  end
20
20
 
21
-
22
21
  before do
23
22
  allow(active_query).to receive(:sfdc_client).and_return client
24
23
  allow(active_query).to receive(:build).and_return Object.new
@@ -40,6 +39,72 @@ describe ActiveForce::ActiveQuery do
40
39
  end
41
40
  end
42
41
 
42
+ describe '#blank? delegation' do
43
+ before do
44
+ allow(client).to receive(:query).and_return(api_result)
45
+ end
46
+
47
+ context 'when there are no records' do
48
+ let(:api_result) { [] }
49
+
50
+ it 'returns true' do
51
+ result = active_query.where("Text_Label = 'foo'").blank?
52
+ expect(result).to be true
53
+ end
54
+ end
55
+
56
+ context 'when records are returned' do
57
+ it 'returns false' do
58
+ result = active_query.where("Text_Label = 'foo'").blank?
59
+ expect(result).to be false
60
+ end
61
+ end
62
+ end
63
+
64
+ describe '#present? delegation' do
65
+ before do
66
+ allow(client).to receive(:query).and_return(api_result)
67
+ end
68
+
69
+ context 'when there are no records' do
70
+ let(:api_result) { [] }
71
+
72
+ it 'returns false' do
73
+ result = active_query.where("Text_Label = 'foo'").present?
74
+ expect(result).to be false
75
+ end
76
+ end
77
+
78
+ context 'when there are records' do
79
+ it 'returns true' do
80
+ result = active_query.where("Text_Label = 'foo'").present?
81
+ expect(result).to be true
82
+ end
83
+ end
84
+ end
85
+
86
+ describe '#any? delegation' do
87
+ before do
88
+ allow(client).to receive(:query).and_return(api_result)
89
+ end
90
+
91
+ context 'when there are no records' do
92
+ let(:api_result) { [] }
93
+
94
+ it 'returns true' do
95
+ result = active_query.where("Text_Label = 'foo'").any?
96
+ expect(result).to be false
97
+ end
98
+ end
99
+
100
+ context 'when records are returned' do
101
+ it 'returns false' do
102
+ result = active_query.where("Text_Label = 'foo'").any?
103
+ expect(result).to be true
104
+ end
105
+ end
106
+ end
107
+
43
108
  describe "select only some field using mappings" do
44
109
  it "should return a query only with selected field" do
45
110
  new_query = active_query.select(:field)
@@ -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,32 @@
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
+ describe '#to_csv' do
13
+ it 'returns CSV with headers' do
14
+ expect(subject.to_csv).to eq "header1,header2\nvalue1,value2\nvalue3,value4\n"
15
+ end
16
+ end
17
+ describe '::parse_from_attributes' do
18
+ subject { described_class.parse_from_attributes(attributes) }
19
+ let(:attributes) do
20
+ [
21
+ { header1: 'value1', header2: 'value2'},
22
+ { header1: 'value3', header2: 'value4'},
23
+ ]
24
+ end
25
+ it 'parses array of hash attributes into Records object with headers and data' do
26
+ records = subject
27
+ expect(records).to be_a described_class
28
+ expect(records.headers).to eq headers
29
+ expect(records.data).to eq data
30
+ end
31
+ end
32
+ 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
@@ -168,14 +168,30 @@ describe ActiveForce::Query do
168
168
  end
169
169
 
170
170
  describe '.last' do
171
- it 'should return the query for the last record' do
172
- expect(query.last.to_s).to eq 'SELECT Id, name, etc FROM table_name ORDER BY Id DESC LIMIT 1'
173
- end
174
-
175
- it "should not update the original query" do
176
- new_query = query.last
177
- expect(query.to_s).to eq "SELECT Id, name, etc FROM table_name"
178
- expect(new_query.to_s).to eq 'SELECT Id, name, etc FROM table_name ORDER BY Id DESC LIMIT 1'
171
+ context 'without any argument' do
172
+ it 'should return the query for the last record' do
173
+ expect(query.last.to_s).to eq 'SELECT Id, name, etc FROM table_name ORDER BY Id DESC LIMIT 1'
174
+ end
175
+
176
+ it "should not update the original query" do
177
+ new_query = query.last
178
+ expect(query.to_s).to eq "SELECT Id, name, etc FROM table_name"
179
+ expect(new_query.to_s).to eq 'SELECT Id, name, etc FROM table_name ORDER BY Id DESC LIMIT 1'
180
+ end
181
+ end
182
+
183
+ context 'with an argument' do
184
+ let(:last_argument) { 3 }
185
+
186
+ it 'should return the query for the last n records' do
187
+ expect(query.last(last_argument).to_s).to eq "SELECT Id, name, etc FROM table_name ORDER BY Id DESC LIMIT #{last_argument}"
188
+ end
189
+
190
+ it "should not update the original query" do
191
+ new_query = query.last last_argument
192
+ expect(query.to_s).to eq "SELECT Id, name, etc FROM table_name"
193
+ expect(new_query.to_s).to eq "SELECT Id, name, etc FROM table_name ORDER BY Id DESC LIMIT #{last_argument}"
194
+ end
179
195
  end
180
196
  end
181
197
 
@@ -225,6 +225,13 @@ module ActiveForce
225
225
  end
226
226
  end
227
227
 
228
+ context 'when assocation has a scope' do
229
+ it 'formulates the correct SOQL query with the scope applied' do
230
+ soql = Post.includes(:impossible_comments).where(id: '1234').to_s
231
+ expect(soql).to eq "SELECT Id, Title__c, BlogId, (SELECT Id, PostId, PosterId__c, FancyPostId, Body__c FROM Comments__r WHERE (1 = 0)) FROM Post__c WHERE (Id = '1234')"
232
+ end
233
+ end
234
+
228
235
  context 'with namespaced SObjects' do
229
236
  it 'formulates the correct SOQL query' do
230
237
  soql = Salesforce::Quota.includes(:prez_clubs).where(id: '123').to_s
@@ -286,9 +293,26 @@ module ActiveForce
286
293
  end
287
294
  end
288
295
 
296
+ context 'has_one' do
297
+ context 'when assocation has a scope' do
298
+ it 'formulates the correct SOQL query with the scope applied' do
299
+ soql = Post.includes(:last_comment).where(id: '1234').to_s
300
+ expect(soql).to eq "SELECT Id, Title__c, BlogId, (SELECT Id, PostId, PosterId__c, FancyPostId, Body__c FROM Comment__r WHERE (NOT ((Body__c = NULL))) ORDER BY CreatedDate DESC) FROM Post__c WHERE (Id = '1234')"
301
+ end
302
+ end
303
+
304
+ end
305
+
289
306
  context 'when invalid associations are passed' do
290
- it 'raises an error' do
291
- expect { Quota.includes(:invalid).find('123') }.to raise_error(ActiveForce::Association::InvalidAssociationError, 'Association named invalid was not found on Quota')
307
+ context 'when the association is not defined' do
308
+ it 'raises an error' do
309
+ expect { Quota.includes(:invalid).find('123') }.to raise_error(ActiveForce::Association::InvalidAssociationError, 'Association named invalid was not found on Quota')
310
+ end
311
+ end
312
+ context 'when the association is scoped and accepts an argument' do
313
+ it 'raises and error' do
314
+ expect { Post.includes(:reply_comments).find('1234')}.to raise_error(ActiveForce::Association::InvalidEagerLoadAssociation)
315
+ end
292
316
  end
293
317
  end
294
318
  end
@@ -313,6 +313,24 @@ describe ActiveForce::SObject do
313
313
  expect(Whizbang.create(text: 'some text')).to be_instance_of(Whizbang)
314
314
  end
315
315
  end
316
+
317
+ describe 'self.update' do
318
+ it 'uses the client to update the correct record' do
319
+ expect(client).to receive(:update!)
320
+ .with(Whizbang.table_name, { 'Id' => '12345678', 'Text_Label' => 'my text', 'Updated_From__c' => 'Rails' })
321
+ .and_return(true)
322
+ Whizbang.update('12345678', text: 'my text')
323
+ end
324
+ end
325
+
326
+ describe 'self.update!' do
327
+ it 'uses the client to update the correct record' do
328
+ expect(client).to receive(:update!)
329
+ .with(Whizbang.table_name, { 'Id' => '123456789', 'Text_Label' => 'some other text', 'Updated_From__c' => 'Rails' })
330
+ .and_return(true)
331
+ Whizbang.update('123456789', text: 'some other text')
332
+ end
333
+ end
316
334
  end
317
335
 
318
336
  describe '.count' do
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_force
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eloy Espinaco
8
8
  - Pablo Oldani
9
9
  - Armando Andini
10
10
  - José Piccioni
11
- autorequire:
11
+ autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2023-08-23 00:00:00.000000000 Z
14
+ date: 2023-11-15 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
@@ -193,7 +201,7 @@ metadata:
193
201
  bug_tracker_uri: https://github.com/Beyond-Finance/active_force/issues
194
202
  changelog_uri: https://github.com/Beyond-Finance/active_force/blob/main/CHANGELOG.md
195
203
  source_code_uri: https://github.com/Beyond-Finance/active_force
196
- post_install_message:
204
+ post_install_message:
197
205
  rdoc_options: []
198
206
  require_paths:
199
207
  - lib
@@ -208,14 +216,18 @@ required_rubygems_version: !ruby/object:Gem::Requirement
208
216
  - !ruby/object:Gem::Version
209
217
  version: '0'
210
218
  requirements: []
211
- rubygems_version: 3.1.6
212
- signing_key:
219
+ rubygems_version: 3.3.26
220
+ signing_key:
213
221
  specification_version: 4
214
222
  summary: Help you implement models persisting on Sales Force within Rails using RESTForce
215
223
  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