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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc1c415b6de1e133528660a416b470d81eee1b8e075f7150ee9244f32d6c9c4c
4
- data.tar.gz: c4a24611cdc27f447c0c3b3e29d14c16c5e8c5b701f9cfcdd6f0469b057e5622
3
+ metadata.gz: c240b297b231441f1e118382265406f86080553212533be3016f5c3c747ddfe6
4
+ data.tar.gz: fa0162bee251048885d820b01c458da369ea4ca902657b097d73a8a5760429fa
5
5
  SHA512:
6
- metadata.gz: 2de4234e2f417c7fc79c04d2dbbc039399f55fa63ea083a5e4b2b45eaf568d18d66fba3d577928d665a4ff816cb45dd15d0169df1837bc4495b6d8d369ebefe5
7
- data.tar.gz: 0d35e111badf4d1226d1a422c586fffe0eaa93fd0c50f54b88b381ba1b062b5460cc035e6989baf15583cae916e42c4e2042fce433fb95bfae0db0ebe0ea2c1d
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
@@ -111,6 +111,10 @@ module ActiveForce
111
111
  clone_and_set_instance_variables(query_fields: ["Id"])
112
112
  end
113
113
 
114
+ def filtered?
115
+ @conditions.any?(&:present?)
116
+ end
117
+
114
118
  protected
115
119
  def quote_string(s)
116
120
  "'#{s.gsub(/(['\\])/, '\\\\\\1')}'"
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveForce
4
- VERSION = '0.27.1'
4
+ VERSION = '0.28.0'
5
5
  end
@@ -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 {:field=>123}")
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.27.1
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