active_force 0.27.1 → 0.28.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/CHANGELOG.md +5 -0
- data/README.md +42 -0
- data/active_force.gemspec +1 -0
- data/lib/active_force/active_query.rb +11 -2
- data/lib/active_force/composite_collection_delete.rb +73 -0
- data/lib/active_force/query.rb +4 -0
- data/lib/active_force/sobject.rb +30 -1
- data/lib/active_force/version.rb +1 -1
- data/spec/active_force/active_query_spec.rb +41 -1
- data/spec/active_force/composite_collection_delete_spec.rb +152 -0
- data/spec/active_force/query_spec.rb +23 -0
- data/spec/active_force/sobject_spec.rb +88 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c240b297b231441f1e118382265406f86080553212533be3016f5c3c747ddfe6
|
|
4
|
+
data.tar.gz: fa0162bee251048885d820b01c458da369ea4ca902657b097d73a8a5760429fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d7ff886ce3c59348c158ce8c9927ca0a6893a72921fab8dd32fced0d153c91c23114ed71a50747f0747a93dd0b80cdd2cc30f82d03680ed9de6ba9a6d34ab0e
|
|
7
|
+
data.tar.gz: 16a6a89dd42bad672ec1702a16560f032df1537834d309b7cd56b57ccc70b554b9f506530ee86caf25ef15a45f6dfc26e5a26ee6d9d8fbcc1d31dd5a4c3ccf6b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Not released
|
|
4
4
|
|
|
5
|
+
## 0.28.0
|
|
6
|
+
- Add `delete` / `delete_all` (class, relation and instance) using the Composite sObject Collections API. Ids are deleted in chunks of 200, each chunk all-or-none, and the first failing chunk raises `ActiveForce::DeleteFailed` with the Salesforce `errors` and the `deleted_count` from earlier chunks, which stay deleted. Nothing is retried, and ids that are already deleted or never existed are errors like any other. (https://github.com/Beyond-Finance/active_force/pull/119)
|
|
7
|
+
- `delete` raises `ArgumentError` before sending any request when an id's key prefix belongs to another object, so `Account.delete(a_contact_id)` no longer deletes the Contact. The prefix comes from `Model.key_prefix`, read from describe once per class and cached. Objects with no key prefix are not checked, and objects that share a prefix (`ContentDocument` and `ContentNote`) can still be mixed up. (https://github.com/Beyond-Finance/active_force/pull/122)
|
|
8
|
+
- `delete_all` raises `ActiveForce::UnscopedDelete` before querying anything when the query has no `where` condition, so `Account.delete_all` can no longer delete every record of an object. Unlike ActiveRecord, `delete_all` is reserved for scoped queries. (https://github.com/Beyond-Finance/active_force/pull/123)
|
|
9
|
+
|
|
5
10
|
## 0.27.1
|
|
6
11
|
- `find` and `find!` quote the id the same way `where` quotes string values. The SOQL does not change for ids without `'` or `\`. (https://github.com/Beyond-Finance/active_force/pull/TBD)
|
|
7
12
|
|
data/README.md
CHANGED
|
@@ -271,6 +271,48 @@ accounts = Account.where(web_enabled: 1).limit(2)
|
|
|
271
271
|
with data from another API, and will only query the other API once.
|
|
272
272
|
```
|
|
273
273
|
|
|
274
|
+
### Deleting
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
Account.find('001...').destroy # runs :destroy callbacks, one API call
|
|
278
|
+
Account.find('001...').delete # no callbacks
|
|
279
|
+
|
|
280
|
+
Account.delete('001...') #=> 1
|
|
281
|
+
Account.delete(['001...', '002...']) #=> 2
|
|
282
|
+
Account.where(web_enable: false).delete_all #=> number of records deleted
|
|
283
|
+
Account.delete_all # raises ActiveForce::UnscopedDelete
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`delete` and `delete_all` skip callbacks and return the number of records deleted. An id that no
|
|
287
|
+
longer exists, or never existed, raises `ActiveForce::DeleteFailed`.
|
|
288
|
+
|
|
289
|
+
Unlike ActiveRecord, `delete_all` needs a `where` condition: on an unfiltered query, including
|
|
290
|
+
`Account.delete_all` and `Account.where(nil).delete_all`, it raises `ActiveForce::UnscopedDelete` before
|
|
291
|
+
querying anything. `limit`, `order` and `offset` are not filters. This guards against deleting
|
|
292
|
+
every record in an object by accident, but it can't tell that a condition such as `where("1=1")`
|
|
293
|
+
matches everything. `Account.delete(ids)` takes an explicit list and is unaffected.
|
|
294
|
+
|
|
295
|
+
The endpoint takes ids only, so it can't tell which object an id belongs to. To guard against
|
|
296
|
+
deleting the wrong record, `Model.delete` compares each id's 3-character key prefix with the
|
|
297
|
+
model's (`Model.key_prefix`, read from Salesforce's describe once per class and cached) and
|
|
298
|
+
raises `ArgumentError` before sending anything if one doesn't match. `Account.delete(a_contact_id)`
|
|
299
|
+
raises. Objects with no key prefix, such as history objects, are not checked. A few objects share a
|
|
300
|
+
prefix (`ContentDocument` and `ContentNote`), so ids of those can still be mixed up.
|
|
301
|
+
|
|
302
|
+
They use the REST Composite sObject Collections endpoint, at most 200 ids per request, so
|
|
303
|
+
`delete_all` on 1,000 records makes about 5 API calls. Each request is sent with `allOrNone`, so
|
|
304
|
+
it deletes all of its records or none of them.
|
|
305
|
+
|
|
306
|
+
ActiveRecord runs `delete_all` as a single atomic statement. Salesforce can only offer that within
|
|
307
|
+
one request, so a call with more than 200 ids is not atomic as a whole. ActiveForce does not retry
|
|
308
|
+
or work around failures: the first request that fails raises `ActiveForce::DeleteFailed` and the
|
|
309
|
+
remaining requests are not sent. It carries `errors` (the Salesforce error details, each with its
|
|
310
|
+
record `id`) and `deleted_count` (records removed by earlier requests, which stay deleted). Calls
|
|
311
|
+
with 200 ids or fewer are fully atomic. That includes an id that no longer exists, which is an
|
|
312
|
+
error like any other and rolls back its request, so the caller decides how to handle it.
|
|
313
|
+
|
|
314
|
+
For large asynchronous deletes, see [Bulk Jobs](#bulk-jobs).
|
|
315
|
+
|
|
274
316
|
### Bulk Jobs
|
|
275
317
|
|
|
276
318
|
For more information about usage and limits of the Salesforce Bulk API see the [docs][4].
|
data/active_force.gemspec
CHANGED
|
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
|
|
|
27
27
|
|
|
28
28
|
spec.add_dependency 'activemodel', '>= 7.0'
|
|
29
29
|
spec.add_dependency 'activesupport', '>= 7.0'
|
|
30
|
+
spec.add_dependency 'csv'
|
|
30
31
|
spec.add_dependency 'restforce', '>= 5'
|
|
31
32
|
spec.add_development_dependency 'rake', '>= 0'
|
|
32
33
|
spec.add_development_dependency 'rspec', '>= 0'
|
|
@@ -24,6 +24,9 @@ module ActiveForce
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Raised by delete_all on a query with no filter conditions.
|
|
28
|
+
class UnscopedDelete < ArgumentError; end
|
|
29
|
+
|
|
27
30
|
class ActiveQuery < Query
|
|
28
31
|
extend Forwardable
|
|
29
32
|
|
|
@@ -92,7 +95,7 @@ module ActiveForce
|
|
|
92
95
|
fields_collection = ActiveForce::SelectBuilder.new(selected_fields, self).parse
|
|
93
96
|
nested_query_fields.concat(fields_collection[:nested_query_fields]) if fields_collection[:nested_query_fields]
|
|
94
97
|
return self if fields_collection[:non_nested_query_fields].blank?
|
|
95
|
-
|
|
98
|
+
|
|
96
99
|
super *fields_collection[:non_nested_query_fields]
|
|
97
100
|
end
|
|
98
101
|
end
|
|
@@ -101,6 +104,12 @@ module ActiveForce
|
|
|
101
104
|
super.pluck(:id)
|
|
102
105
|
end
|
|
103
106
|
|
|
107
|
+
def delete_all
|
|
108
|
+
raise UnscopedDelete, "delete_all requires a where condition on #{table_name}" unless filtered?
|
|
109
|
+
|
|
110
|
+
sobject.delete(ids)
|
|
111
|
+
end
|
|
112
|
+
|
|
104
113
|
def find!(id)
|
|
105
114
|
result = find(id)
|
|
106
115
|
raise RecordNotFound.new("Couldn't find #{table_name} with id #{id}", table_name, id: id) if result.nil?
|
|
@@ -120,7 +129,7 @@ module ActiveForce
|
|
|
120
129
|
end
|
|
121
130
|
|
|
122
131
|
def includes(*relations)
|
|
123
|
-
includes_query = Association::EagerLoadBuilderForNestedIncludes.build(relations, sobject, nil, nested_query_fields)
|
|
132
|
+
includes_query = Association::EagerLoadBuilderForNestedIncludes.build(relations, sobject, nil, nested_query_fields)
|
|
124
133
|
fields includes_query[:fields]
|
|
125
134
|
association_mapping.merge!(includes_query[:association_mapping])
|
|
126
135
|
self
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require 'active_support/all'
|
|
2
|
+
|
|
3
|
+
module ActiveForce
|
|
4
|
+
# Raised when Salesforce rejects a delete. +errors+ holds the failing
|
|
5
|
+
# per-record results and +deleted_count+ the records removed by earlier
|
|
6
|
+
# chunks of the same call, which stay deleted.
|
|
7
|
+
class DeleteFailed < StandardError
|
|
8
|
+
attr_reader :errors, :deleted_count
|
|
9
|
+
|
|
10
|
+
def initialize(errors, deleted_count)
|
|
11
|
+
@errors = errors
|
|
12
|
+
@deleted_count = deleted_count
|
|
13
|
+
|
|
14
|
+
super("Delete failed after deleting #{deleted_count} record(s): #{errors.to_json}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Deletes records through the Composite sObject Collections endpoint
|
|
19
|
+
# (DELETE /composite/sobjects). Salesforce accepts at most 200 ids per request,
|
|
20
|
+
# so ids are sent in chunks of 200. Each chunk is all-or-none and chunks are
|
|
21
|
+
# independent of each other. The first chunk with a failing record raises
|
|
22
|
+
# DeleteFailed and the remaining chunks are not sent; nothing is retried or
|
|
23
|
+
# worked around, including ids that are already deleted or never existed.
|
|
24
|
+
class CompositeCollectionDelete
|
|
25
|
+
CHUNK_SIZE = 200
|
|
26
|
+
ROLLED_BACK = 'ALL_OR_NONE_OPERATION_ROLLED_BACK'.freeze
|
|
27
|
+
|
|
28
|
+
def self.call(ids, sfdc_client = ActiveForce.sfdc_client)
|
|
29
|
+
new(ids, sfdc_client).call
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :ids, :sfdc_client, :deleted_count
|
|
33
|
+
|
|
34
|
+
def initialize(ids, sfdc_client)
|
|
35
|
+
@ids = Array(ids).flatten.reject(&:blank?).uniq
|
|
36
|
+
@sfdc_client = sfdc_client
|
|
37
|
+
@deleted_count = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the number of records deleted.
|
|
41
|
+
def call
|
|
42
|
+
ids.each_slice(CHUNK_SIZE) { |chunk| delete_chunk(chunk) }
|
|
43
|
+
deleted_count
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def delete_chunk(chunk)
|
|
49
|
+
failures = chunk.zip(request(chunk)).reject { |_, result| result['success'] }
|
|
50
|
+
raise DeleteFailed.new(errors_for(failures), deleted_count) if failures.any?
|
|
51
|
+
|
|
52
|
+
@deleted_count += chunk.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def request(chunk)
|
|
56
|
+
sfdc_client.api_delete('composite/sobjects', ids: chunk.join(','), allOrNone: true).body
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reports the records that failed for a reason of their own. Records only
|
|
60
|
+
# rolled back because another one failed are left out, unless nothing else
|
|
61
|
+
# explains the failure.
|
|
62
|
+
def errors_for(failures)
|
|
63
|
+
causes = failures.reject { |_, result| rolled_back?(result) }
|
|
64
|
+
(causes.presence || failures).flat_map do |id, result|
|
|
65
|
+
result['errors'].map { |error| error.to_h.merge('id' => id) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rolled_back?(result)
|
|
70
|
+
result['errors'].map { |error| error['statusCode'] } == [ROLLED_BACK]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/active_force/query.rb
CHANGED
data/lib/active_force/sobject.rb
CHANGED
|
@@ -2,6 +2,7 @@ require 'active_model'
|
|
|
2
2
|
require 'active_force/active_query'
|
|
3
3
|
require 'active_force/association'
|
|
4
4
|
require 'active_force/bulk'
|
|
5
|
+
require 'active_force/composite_collection_delete'
|
|
5
6
|
require 'active_force/mapping'
|
|
6
7
|
require 'yaml'
|
|
7
8
|
require 'forwardable'
|
|
@@ -32,10 +33,22 @@ module ActiveForce
|
|
|
32
33
|
|
|
33
34
|
class << self
|
|
34
35
|
extend Forwardable
|
|
35
|
-
def_delegators :query, :not, :or, :where, :first, :last, :all, :find, :find!, :find_by, :find_by!, :sum, :count, :includes, :limit, :order, :select, :none
|
|
36
|
+
def_delegators :query, :not, :or, :where, :first, :last, :all, :find, :find!, :find_by, :find_by!, :sum, :count, :includes, :limit, :order, :select, :none, :delete_all
|
|
36
37
|
def_delegators :mapping, :table, :custom_table?, :mappings
|
|
37
38
|
alias_method :table_name=, :table_name_store=
|
|
38
39
|
|
|
40
|
+
def delete(id_or_ids)
|
|
41
|
+
ids = Array(id_or_ids)
|
|
42
|
+
ensure_ids_belong_to_this_object!(ids)
|
|
43
|
+
CompositeCollectionDelete.call(ids, sfdc_client)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def key_prefix
|
|
47
|
+
return @key_prefix if defined?(@key_prefix)
|
|
48
|
+
|
|
49
|
+
@key_prefix = describe['keyPrefix'].presence
|
|
50
|
+
end
|
|
51
|
+
|
|
39
52
|
def update(id, attributes)
|
|
40
53
|
prepare_for_update(id, attributes).update
|
|
41
54
|
end
|
|
@@ -50,6 +63,17 @@ module ActiveForce
|
|
|
50
63
|
|
|
51
64
|
private
|
|
52
65
|
|
|
66
|
+
def ensure_ids_belong_to_this_object!(ids)
|
|
67
|
+
ids = ids.reject(&:blank?)
|
|
68
|
+
return if ids.empty? || key_prefix.nil?
|
|
69
|
+
|
|
70
|
+
foreign = ids.reject { |id| id.to_s.start_with?(key_prefix) }
|
|
71
|
+
return if foreign.empty?
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "Cannot delete #{foreign.first(5).join(', ')} through #{name}: " \
|
|
74
|
+
"#{table_name} ids start with #{key_prefix}"
|
|
75
|
+
end
|
|
76
|
+
|
|
53
77
|
def prepare_for_update(id, attributes)
|
|
54
78
|
new(attributes.merge(id: id)).tap do |obj|
|
|
55
79
|
attributes.each do |name, value|
|
|
@@ -163,6 +187,11 @@ module ActiveForce
|
|
|
163
187
|
end
|
|
164
188
|
end
|
|
165
189
|
|
|
190
|
+
def delete
|
|
191
|
+
self.class.delete(id)
|
|
192
|
+
self
|
|
193
|
+
end
|
|
194
|
+
|
|
166
195
|
def self.create args
|
|
167
196
|
new(args).create
|
|
168
197
|
end
|
data/lib/active_force/version.rb
CHANGED
|
@@ -444,7 +444,7 @@ describe ActiveForce::ActiveQuery do
|
|
|
444
444
|
it 'raises if record not found' do
|
|
445
445
|
allow(client).to receive(:query).and_return(build_restforce_collection)
|
|
446
446
|
expect { active_query.find_by!(field: 123) }
|
|
447
|
-
.to raise_error(ActiveForce::RecordNotFound, "Couldn't find #{sobject.table_name} with {:
|
|
447
|
+
.to raise_error(ActiveForce::RecordNotFound, "Couldn't find #{sobject.table_name} with #{{ field: 123 }}")
|
|
448
448
|
end
|
|
449
449
|
|
|
450
450
|
it 'should raise UnknownFieldError if given invalid field' do
|
|
@@ -634,4 +634,44 @@ describe ActiveForce::ActiveQuery do
|
|
|
634
634
|
expect(active_query.ids).to eq api_result.map { |r| r['Id'] }
|
|
635
635
|
end
|
|
636
636
|
end
|
|
637
|
+
|
|
638
|
+
describe "#delete_all" do
|
|
639
|
+
before do
|
|
640
|
+
allow(client).to receive(:query).and_return(api_result)
|
|
641
|
+
api_result.each do |instance|
|
|
642
|
+
allow(active_query).to receive(:build).with(instance, {}).and_return(build_restforce_sobject(id: instance['Id']))
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
it "deletes the ids matched by the query and returns the count" do
|
|
647
|
+
expect(sobject).to receive(:delete).with(%w[0000000000AAAAABBB 0000000000CCCCCDDD]).and_return(2)
|
|
648
|
+
expect(active_query.where(field: 'x').delete_all).to eq 2
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
it "does not delete anything when nothing matches" do
|
|
652
|
+
allow(client).to receive(:query).and_return([])
|
|
653
|
+
expect(sobject).to receive(:delete).with([]).and_return(0)
|
|
654
|
+
expect(active_query.where(field: 'x').delete_all).to eq 0
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
describe "without a where condition" do
|
|
658
|
+
before { expect(client).not_to receive(:query) }
|
|
659
|
+
|
|
660
|
+
it "raises before running any query" do
|
|
661
|
+
expect(sobject).not_to receive(:delete)
|
|
662
|
+
expect { active_query.delete_all }
|
|
663
|
+
.to raise_error(ActiveForce::UnscopedDelete, /delete_all requires a where condition on #{sobject.table_name}/)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
it "raises for where(nil) and non-filtering modifiers" do
|
|
667
|
+
[active_query.where(nil), active_query.limit(10), active_query.order(:field)].each do |query|
|
|
668
|
+
expect { query.delete_all }.to raise_error(ActiveForce::UnscopedDelete)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
it "is an ArgumentError" do
|
|
673
|
+
expect { active_query.delete_all }.to raise_error(ArgumentError)
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
end
|
|
637
677
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe ActiveForce::CompositeCollectionDelete do
|
|
4
|
+
let(:sfdc_client) { double('Client') }
|
|
5
|
+
|
|
6
|
+
def success(id)
|
|
7
|
+
{ 'id' => id, 'success' => true, 'errors' => [] }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def failure(id, status_code, message = 'boom')
|
|
11
|
+
{ 'id' => id, 'success' => false, 'errors' => [{ 'statusCode' => status_code, 'message' => message, 'fields' => [] }] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rolled_back(id)
|
|
15
|
+
failure(id, 'ALL_OR_NONE_OPERATION_ROLLED_BACK', 'Record rolled back because not all records were valid.')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def response(results)
|
|
19
|
+
double('Response', body: results)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe '.call' do
|
|
23
|
+
it 'defaults to ActiveForce.sfdc_client' do
|
|
24
|
+
allow(ActiveForce).to receive(:sfdc_client).and_return(sfdc_client)
|
|
25
|
+
allow(sfdc_client).to receive(:api_delete).and_return(response([success('001')]))
|
|
26
|
+
|
|
27
|
+
described_class.call(['001'])
|
|
28
|
+
|
|
29
|
+
expect(sfdc_client).to have_received(:api_delete)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#call' do
|
|
34
|
+
it 'deletes a single id via the composite sobjects endpoint with allOrNone' do
|
|
35
|
+
expect(sfdc_client).to receive(:api_delete)
|
|
36
|
+
.with('composite/sobjects', ids: '001', allOrNone: true)
|
|
37
|
+
.and_return(response([success('001')]))
|
|
38
|
+
|
|
39
|
+
expect(described_class.call(['001'], sfdc_client)).to eq 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'sends multiple ids comma separated and returns the count deleted' do
|
|
43
|
+
expect(sfdc_client).to receive(:api_delete)
|
|
44
|
+
.with('composite/sobjects', ids: '001,002,003', allOrNone: true)
|
|
45
|
+
.and_return(response(%w[001 002 003].map { |id| success(id) }))
|
|
46
|
+
|
|
47
|
+
expect(described_class.call(%w[001 002 003], sfdc_client)).to eq 3
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'ignores blank and duplicate ids' do
|
|
51
|
+
expect(sfdc_client).to receive(:api_delete)
|
|
52
|
+
.with('composite/sobjects', ids: '001,002', allOrNone: true)
|
|
53
|
+
.and_return(response([success('001'), success('002')]))
|
|
54
|
+
|
|
55
|
+
expect(described_class.call(['001', nil, '', '001', '002'], sfdc_client)).to eq 2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'makes no request and returns 0 when there are no ids' do
|
|
59
|
+
expect(sfdc_client).not_to receive(:api_delete)
|
|
60
|
+
|
|
61
|
+
expect(described_class.call([nil], sfdc_client)).to eq 0
|
|
62
|
+
expect(described_class.call([], sfdc_client)).to eq 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
context 'with more ids than fit in one request' do
|
|
66
|
+
let(:ids) { (1..450).map { |i| format('%018d', i) } }
|
|
67
|
+
|
|
68
|
+
it 'chunks into requests of 200 ids' do
|
|
69
|
+
sent = []
|
|
70
|
+
allow(sfdc_client).to receive(:api_delete) do |_path, params|
|
|
71
|
+
chunk = params[:ids].split(',')
|
|
72
|
+
sent << chunk
|
|
73
|
+
response(chunk.map { |id| success(id) })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
expect(described_class.call(ids, sfdc_client)).to eq 450
|
|
77
|
+
expect(sent.map(&:size)).to eq [200, 200, 50]
|
|
78
|
+
expect(sent.flatten).to eq ids
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
context 'when a record fails' do
|
|
83
|
+
it 'raises DeleteFailed with the error details and does not retry' do
|
|
84
|
+
expect(sfdc_client).to receive(:api_delete).once
|
|
85
|
+
.and_return(response([failure('001', 'DELETE_FAILED', 'has child records'), rolled_back('002')]))
|
|
86
|
+
|
|
87
|
+
expect { described_class.call(%w[001 002], sfdc_client) }
|
|
88
|
+
.to raise_error(ActiveForce::DeleteFailed) { |error|
|
|
89
|
+
expect(error.deleted_count).to eq 0
|
|
90
|
+
expect(error.errors).to eq [{ 'id' => '001', 'statusCode' => 'DELETE_FAILED', 'message' => 'has child records', 'fields' => [] }]
|
|
91
|
+
expect(error.message).to include('DELETE_FAILED', 'has child records')
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
%w[ENTITY_IS_DELETED INVALID_CROSS_REFERENCE_KEY].each do |code|
|
|
96
|
+
it "raises for a missing id (#{code}) instead of working around it" do
|
|
97
|
+
expect(sfdc_client).to receive(:api_delete).once
|
|
98
|
+
.and_return(response([success('001'), failure('002', code), rolled_back('003')]))
|
|
99
|
+
|
|
100
|
+
expect { described_class.call(%w[001 002 003], sfdc_client) }
|
|
101
|
+
.to raise_error(ActiveForce::DeleteFailed) { |error|
|
|
102
|
+
expect(error.errors.map { |e| [e['id'], e['statusCode']] }).to eq [['002', code]]
|
|
103
|
+
expect(error.deleted_count).to eq 0
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'reports every record that failed for its own reason' do
|
|
109
|
+
allow(sfdc_client).to receive(:api_delete)
|
|
110
|
+
.and_return(response([failure('001', 'ENTITY_IS_DELETED'), failure('002', 'DELETE_FAILED'), rolled_back('003')]))
|
|
111
|
+
|
|
112
|
+
expect { described_class.call(%w[001 002 003], sfdc_client) }
|
|
113
|
+
.to raise_error(ActiveForce::DeleteFailed) { |error| expect(error.errors.map { |e| e['id'] }).to eq %w[001 002] }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'raises rather than report success when every result was rolled back' do
|
|
117
|
+
allow(sfdc_client).to receive(:api_delete)
|
|
118
|
+
.and_return(response([rolled_back('001'), rolled_back('002')]))
|
|
119
|
+
|
|
120
|
+
expect { described_class.call(%w[001 002], sfdc_client) }
|
|
121
|
+
.to raise_error(ActiveForce::DeleteFailed) { |error|
|
|
122
|
+
expect(error.deleted_count).to eq 0
|
|
123
|
+
expect(error.errors.map { |e| e['statusCode'] }).to eq %w[ALL_OR_NONE_OPERATION_ROLLED_BACK] * 2
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
context 'when a later chunk fails' do
|
|
129
|
+
let(:ids) { (1..450).map { |i| format('%018d', i) } }
|
|
130
|
+
|
|
131
|
+
it 'reports the earlier chunks as deleted and does not send the rest' do
|
|
132
|
+
sent = []
|
|
133
|
+
allow(sfdc_client).to receive(:api_delete) do |_path, params|
|
|
134
|
+
chunk = params[:ids].split(',')
|
|
135
|
+
sent << chunk
|
|
136
|
+
if sent.size == 1
|
|
137
|
+
response(chunk.map { |id| success(id) })
|
|
138
|
+
else
|
|
139
|
+
response(chunk.map { |id| id == chunk.first ? failure(id, 'DELETE_FAILED') : rolled_back(id) })
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
expect { described_class.call(ids, sfdc_client) }
|
|
144
|
+
.to raise_error(ActiveForce::DeleteFailed) { |error|
|
|
145
|
+
expect(error.deleted_count).to eq 200
|
|
146
|
+
expect(error.errors.map { |e| e['id'] }).to eq [ids[200]]
|
|
147
|
+
expect(sent.size).to eq 2
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -269,4 +269,27 @@ describe ActiveForce::Query do
|
|
|
269
269
|
expect(query.where("name = 'cool'").ids.to_s).to eq "SELECT Id FROM table_name WHERE (name = 'cool')"
|
|
270
270
|
end
|
|
271
271
|
end
|
|
272
|
+
|
|
273
|
+
describe ".filtered?" do
|
|
274
|
+
it "is false without conditions" do
|
|
275
|
+
expect(query.filtered?).to be false
|
|
276
|
+
expect(query.all.filtered?).to be false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "is false for limit, order and offset" do
|
|
280
|
+
expect(query.limit(5).order('name').offset(2).filtered?).to be false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "is false for a blank condition" do
|
|
284
|
+
expect(query.where('').filtered?).to be false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it "is true once a condition is added" do
|
|
288
|
+
expect(query.where("name = 'x'").filtered?).to be true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
it "is true after or" do
|
|
292
|
+
expect(query.where("name = 'x'").or(query.where("name = 'y'")).filtered?).to be true
|
|
293
|
+
end
|
|
294
|
+
end
|
|
272
295
|
end
|
|
@@ -302,6 +302,21 @@ describe ActiveForce::SObject do
|
|
|
302
302
|
end
|
|
303
303
|
end
|
|
304
304
|
|
|
305
|
+
describe "#delete" do
|
|
306
|
+
before { allow(Whizbang).to receive(:key_prefix).and_return(nil) }
|
|
307
|
+
|
|
308
|
+
it "deletes by id without running callbacks and returns itself" do
|
|
309
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(['1'], client).and_return(1)
|
|
310
|
+
expect(instance).not_to receive(:run_callbacks)
|
|
311
|
+
expect(instance.delete).to eq instance
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it "does not call the API for a record without an id" do
|
|
315
|
+
expect(client).not_to receive(:api_delete)
|
|
316
|
+
Whizbang.new.delete
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
305
320
|
describe 'self.create' do
|
|
306
321
|
before do
|
|
307
322
|
expect(client).to receive(:create!)
|
|
@@ -661,4 +676,77 @@ describe ActiveForce::SObject do
|
|
|
661
676
|
end
|
|
662
677
|
end
|
|
663
678
|
end
|
|
679
|
+
|
|
680
|
+
describe ".key_prefix" do
|
|
681
|
+
after { Whizbang.remove_instance_variable(:@key_prefix) if Whizbang.instance_variable_defined?(:@key_prefix) }
|
|
682
|
+
|
|
683
|
+
it "reads the keyPrefix from describe" do
|
|
684
|
+
allow(client).to receive(:describe).with('Whizbang__c').and_return('keyPrefix' => 'a0X')
|
|
685
|
+
expect(Whizbang.key_prefix).to eq 'a0X'
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
it "describes once per class" do
|
|
689
|
+
expect(client).to receive(:describe).once.and_return('keyPrefix' => 'a0X')
|
|
690
|
+
2.times { Whizbang.key_prefix }
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
it "caches a missing prefix as nil without describing again" do
|
|
694
|
+
expect(client).to receive(:describe).once.and_return('keyPrefix' => nil)
|
|
695
|
+
2.times { expect(Whizbang.key_prefix).to be_nil }
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
describe ".delete" do
|
|
700
|
+
before { allow(Whizbang).to receive(:key_prefix).and_return('a0X') }
|
|
701
|
+
|
|
702
|
+
it "deletes a single id and returns the count" do
|
|
703
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(['a0X1'], client).and_return(1)
|
|
704
|
+
expect(Whizbang.delete('a0X1')).to eq 1
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
it "deletes an array of ids" do
|
|
708
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(%w[a0X1 a0X2], client).and_return(2)
|
|
709
|
+
expect(Whizbang.delete(%w[a0X1 a0X2])).to eq 2
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
it "accepts 15 and 18 character ids with the right prefix" do
|
|
713
|
+
ids = %w[a0X000000000001 a0X000000000002AAA]
|
|
714
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(ids, client).and_return(2)
|
|
715
|
+
expect(Whizbang.delete(ids)).to eq 2
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
it "raises before any request when an id belongs to another object" do
|
|
719
|
+
expect(ActiveForce::CompositeCollectionDelete).not_to receive(:call)
|
|
720
|
+
expect { Whizbang.delete(%w[a0X000000000001AAA 003000000000001AAA]) }
|
|
721
|
+
.to raise_error(ArgumentError, /003000000000001AAA.*Whizbang__c ids start with a0X/)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
it "does not describe when there are no ids" do
|
|
725
|
+
allow(Whizbang).to receive(:key_prefix).and_call_original
|
|
726
|
+
expect(client).not_to receive(:describe)
|
|
727
|
+
expect(Whizbang.delete([nil, ''])).to eq 0
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
it "skips the check when the object has no key prefix" do
|
|
731
|
+
allow(Whizbang).to receive(:key_prefix).and_return(nil)
|
|
732
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(['003000000000001AAA'], client).and_return(1)
|
|
733
|
+
expect(Whizbang.delete('003000000000001AAA')).to eq 1
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
describe ".delete_all" do
|
|
738
|
+
it "is delegated to the query" do
|
|
739
|
+
query = instance_double(ActiveForce::ActiveQuery)
|
|
740
|
+
allow(Whizbang).to receive(:query).and_return(query)
|
|
741
|
+
expect(query).to receive(:delete_all).and_return(3)
|
|
742
|
+
expect(Whizbang.delete_all).to eq 3
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
it "refuses an unscoped delete before any request" do
|
|
746
|
+
expect(client).not_to receive(:query)
|
|
747
|
+
expect(client).not_to receive(:api_delete)
|
|
748
|
+
expect { Whizbang.delete_all }.to raise_error(ActiveForce::UnscopedDelete)
|
|
749
|
+
expect { Whizbang.where(nil).delete_all }.to raise_error(ActiveForce::UnscopedDelete)
|
|
750
|
+
end
|
|
751
|
+
end
|
|
664
752
|
end
|
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.28.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eloy Espinaco
|
|
@@ -40,6 +40,20 @@ dependencies:
|
|
|
40
40
|
- - ">="
|
|
41
41
|
- !ruby/object:Gem::Version
|
|
42
42
|
version: '7.0'
|
|
43
|
+
- !ruby/object:Gem::Dependency
|
|
44
|
+
name: csv
|
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0'
|
|
43
57
|
- !ruby/object:Gem::Dependency
|
|
44
58
|
name: restforce
|
|
45
59
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -173,6 +187,7 @@ files:
|
|
|
173
187
|
- lib/active_force/bulk/job_result.rb
|
|
174
188
|
- lib/active_force/bulk/records.rb
|
|
175
189
|
- lib/active_force/composite_batch_query.rb
|
|
190
|
+
- lib/active_force/composite_collection_delete.rb
|
|
176
191
|
- lib/active_force/field.rb
|
|
177
192
|
- lib/active_force/mapping.rb
|
|
178
193
|
- lib/active_force/query.rb
|
|
@@ -196,6 +211,7 @@ files:
|
|
|
196
211
|
- spec/active_force/bulk_spec.rb
|
|
197
212
|
- spec/active_force/callbacks_spec.rb
|
|
198
213
|
- spec/active_force/composite_batch_query_spec.rb
|
|
214
|
+
- spec/active_force/composite_collection_delete_spec.rb
|
|
199
215
|
- spec/active_force/field_spec.rb
|
|
200
216
|
- spec/active_force/mapping_spec.rb
|
|
201
217
|
- spec/active_force/query_notifications_spec.rb
|
|
@@ -247,6 +263,7 @@ test_files:
|
|
|
247
263
|
- spec/active_force/bulk_spec.rb
|
|
248
264
|
- spec/active_force/callbacks_spec.rb
|
|
249
265
|
- spec/active_force/composite_batch_query_spec.rb
|
|
266
|
+
- spec/active_force/composite_collection_delete_spec.rb
|
|
250
267
|
- spec/active_force/field_spec.rb
|
|
251
268
|
- spec/active_force/mapping_spec.rb
|
|
252
269
|
- spec/active_force/query_notifications_spec.rb
|