active_force 0.27.1 → 0.28.1
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/.claude/skills/release/SKILL.md +91 -0
- data/CHANGELOG.md +8 -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 +31 -2
- 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 +198 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9282bb2814d5d90acdad820f72977aee58dfa17af2feff228e05a68c77b1cece
|
|
4
|
+
data.tar.gz: 34e234b1cf52121f65853f9fea12c438b344a800f5d3623931b2d5b45230dd43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e056683a3151f3528e3ee1d5bae676ae6f0366ce4fdf4af718b28366c8fbd2114401860fe781fddff0722b40db97ca16f1560eac34e5bb9b5f9cad72a74311e3
|
|
7
|
+
data.tar.gz: ca35e7aacf091512287c65e4a7e816f0fe1eb027716dd67d498b6fe4c9c8d6a4156b2b54898e9c1b90d48b7b8e8abb095eecaa4f140b6777a0905ad88093b9fc
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: release
|
|
3
|
+
description: Release a new version of the active_force gem. Use when asked to cut, prepare, or publish a release. Phase 1 (default) prepares the release PR; phase 2 (`publish`, after the PR is merged) builds and pushes the gem and creates the GitHub release.
|
|
4
|
+
argument-hint: "[version|publish] [--dry-run]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Releasing active_force
|
|
8
|
+
|
|
9
|
+
Two phases, split by the release PR being reviewed and merged. The argument is either a version or `publish`, never both.
|
|
10
|
+
|
|
11
|
+
- A version (`0.28.0`, no `v`) runs phase 1 with that version.
|
|
12
|
+
- No argument runs phase 1 and follows "Choosing the version" below.
|
|
13
|
+
- `publish` runs phase 2. It takes no version: it reads `VERSION` from `lib/active_force/version.rb` on an up-to-date `main`, which is the merged release.
|
|
14
|
+
|
|
15
|
+
`--dry-run` can be added to either form. See "Dry run" below.
|
|
16
|
+
|
|
17
|
+
## Dry run
|
|
18
|
+
|
|
19
|
+
With `--dry-run`, work through the phase as written but change nothing: describe what each step would do, with the real values filled in, and stop at the end. Print the plan as a numbered list in the same order as the steps, marking each as `would do`, `would skip (already done)` or `blocked (reason)`.
|
|
20
|
+
|
|
21
|
+
Only these commands are allowed:
|
|
22
|
+
- Read-only git: `git status`, `git fetch`, `git log`, `git diff`, `git show origin/main:<path>`, `git ls-remote --tags origin <tag>`. Do not switch branches or pull; read `main` through `origin/main` instead.
|
|
23
|
+
- Read-only GitHub: `gh pr list`, `gh pr view`, `gh release list`, `gh release view`, and the `generate-notes` API call (it only returns text).
|
|
24
|
+
- `gem list -r`.
|
|
25
|
+
|
|
26
|
+
Do not run `git switch`, `git pull`, `git commit`, `git push`, `gem build`, `gem push`, `gh pr create`, `gh release create`, or edit any file. If no version was given, still propose one and ask for it: the plan depends on it. Do not ask for the confirmations that precede `gem push` or `gh release create`; show what you would ask instead.
|
|
27
|
+
|
|
28
|
+
What to show:
|
|
29
|
+
- **Phase 1:** the chosen version, the branch name, the PR title, the exact `CHANGELOG.md` diff and `version.rb` change as they would be written, any `TBD` links, and the result of the already-released check.
|
|
30
|
+
- **Phase 2:** the version read from `origin/main`, whether the release PR is merged (and its number), the already-done check for rubygems, the GitHub release and the tag, the commands that would build and push, and the **final release notes** with the release PR's bullet removed.
|
|
31
|
+
|
|
32
|
+
End by saying nothing was changed, and that running the same command without `--dry-run` would do it for real.
|
|
33
|
+
|
|
34
|
+
## Choosing the version
|
|
35
|
+
|
|
36
|
+
Read the `## Not released` section of `CHANGELOG.md` and the current `ActiveForce::VERSION` in `lib/active_force/version.rb`.
|
|
37
|
+
|
|
38
|
+
- If there are no entries under `Not released`, stop and say there is nothing to release.
|
|
39
|
+
- Otherwise propose a bump (new feature or behavior change = minor, fixes only = patch, breaking change = major; this gem is 0.x so breaking changes are also a minor bump) with a one-line reason, and **ask the user to confirm or give a different version**. Never pick one silently.
|
|
40
|
+
|
|
41
|
+
## Phase 1: prepare the release PR
|
|
42
|
+
|
|
43
|
+
1. Require no modified or staged tracked files (`git status --untracked-files=no` is empty); untracked files don't matter. `git switch main && git pull`.
|
|
44
|
+
2. Check the `Not released` entries for placeholder links (`pull/TBD`). If any are found, list them and ask the user before continuing.
|
|
45
|
+
3. Refuse if `{version}` is already released: a git tag `{version}` exists, or `gem list -r -e active_force --all` includes it. `{version}` must also be greater than the current `VERSION`.
|
|
46
|
+
4. `git switch -c release_{version}`.
|
|
47
|
+
5. Set `VERSION = '{version}'` in `lib/active_force/version.rb`.
|
|
48
|
+
6. In `CHANGELOG.md`, insert a new `## {version}` heading directly under `## Not released`, so the existing entries fall under it, and leave `## Not released` at the top, empty. Result:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
# Changelog
|
|
52
|
+
|
|
53
|
+
## Not released
|
|
54
|
+
|
|
55
|
+
## {version}
|
|
56
|
+
- ...entries...
|
|
57
|
+
```
|
|
58
|
+
7. Do not touch `Gemfile.lock` (gitignored) and never `git add` `*.gem` files. Stage only `version.rb` and `CHANGELOG.md`.
|
|
59
|
+
8. Commit as `Release {version}`, push, and open a PR titled `Release {version}`. **Do not use the Linear `KEY-N | TYPE | title` format for release PRs.** The body can be the changelog entries being released.
|
|
60
|
+
9. Report the PR URL and stop. Someone else approves and merges it. Do not merge it.
|
|
61
|
+
|
|
62
|
+
## Phase 2: publish (only after the release PR is merged)
|
|
63
|
+
|
|
64
|
+
Naming, matching past releases: the git tag is `{version}` (no `v`) and the GitHub release title is `v{version}`.
|
|
65
|
+
|
|
66
|
+
1. `git switch main && git pull`. Read `{version}` from `version.rb`.
|
|
67
|
+
2. Confirm the release PR is merged: `gh pr list --head release_{version} --state merged --json number,title`. If it isn't, stop and say so. This works after the branch is deleted. Keep the PR number for step 6.
|
|
68
|
+
3. **Check what is already done**, so a rerun resumes instead of failing partway. Report the result to the user, skip the steps that are done, and continue with the rest:
|
|
69
|
+
- On rubygems: `gem list -r -e active_force --all` includes `{version}` -> skip build and push (steps 4-5).
|
|
70
|
+
- GitHub release: `gh release view {version}` succeeds -> skip notes and release (steps 6-7). If it succeeds, tell the user the release already exists and give the link.
|
|
71
|
+
- A tag `{version}` exists (`git ls-remote --tags origin {version}`) with no GitHub release: stop and ask the user. The tag may point at the wrong commit, so don't create a release on it unprompted.
|
|
72
|
+
- If everything is done, say so and stop.
|
|
73
|
+
4. `gem build active_force.gemspec`.
|
|
74
|
+
5. **Ask for explicit confirmation, then** `gem push active_force-{version}.gem`. A push cannot be undone. If rubygems needs an MFA code, ask the user to run it themselves with `! gem push active_force-{version}.gem`.
|
|
75
|
+
6. Generate release notes and drop the release PR's own entry:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
gh api repos/{owner}/{repo}/releases/generate-notes \
|
|
79
|
+
-f tag_name={version} -f previous_tag_name={previous_tag} -f target_commitish=main \
|
|
80
|
+
--jq .body
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`{previous_tag}` is `gh release list --limit 1 --json tagName --jq '.[0].tagName'`. Remove the bullet that links to the release PR's number (from step 2); do not match on its title. Keep everything else, including the "Full Changelog" line.
|
|
84
|
+
7. Show the user the final notes and ask to confirm. Then:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
gh release create {version} --target main --title "v{version}" --notes-file <file>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This creates the tag.
|
|
91
|
+
8. Report the rubygems and GitHub release links. Leave the built `.gem` file alone (gitignored).
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Not released
|
|
4
4
|
|
|
5
|
+
## 0.28.1
|
|
6
|
+
- `modified_attributes` computes the changed attribute names once instead of once per attribute, making it linear in the number of fields. The result is unchanged. (https://github.com/Beyond-Finance/active_force/pull/126)
|
|
7
|
+
|
|
8
|
+
## 0.28.0
|
|
9
|
+
- 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)
|
|
10
|
+
- `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)
|
|
11
|
+
- `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)
|
|
12
|
+
|
|
5
13
|
## 0.27.1
|
|
6
14
|
- `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
15
|
|
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
|
|
@@ -204,7 +233,7 @@ module ActiveForce
|
|
|
204
233
|
end
|
|
205
234
|
|
|
206
235
|
def modified_attributes
|
|
207
|
-
attributes.
|
|
236
|
+
attributes.slice(*changed)
|
|
208
237
|
end
|
|
209
238
|
|
|
210
239
|
def reload
|
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!)
|
|
@@ -532,6 +547,116 @@ describe ActiveForce::SObject do
|
|
|
532
547
|
end
|
|
533
548
|
end
|
|
534
549
|
|
|
550
|
+
describe '#modified_attributes' do
|
|
551
|
+
context 'with a new instance' do
|
|
552
|
+
it 'returns an empty hash when nothing has been assigned' do
|
|
553
|
+
expect(Whizbang.new.modified_attributes).to eq({})
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
it 'returns only the assigned attributes' do
|
|
557
|
+
instance = Whizbang.new(text: 'some text', boolean: false)
|
|
558
|
+
|
|
559
|
+
expect(instance.modified_attributes).to eq('text' => 'some text', 'boolean' => false)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
it 'returns the cast values of the assigned attributes' do
|
|
563
|
+
instance = Whizbang.new(date: '2010-01-01', percent: '21')
|
|
564
|
+
|
|
565
|
+
expect(instance.modified_attributes).to eq('date' => Date.new(2010, 1, 1), 'percent' => 21.0)
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
context 'with a built instance' do
|
|
570
|
+
let(:instance) { Whizbang.build sobject_hash }
|
|
571
|
+
|
|
572
|
+
it 'returns an empty hash when nothing has changed' do
|
|
573
|
+
expect(instance.modified_attributes).to eq({})
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
it 'returns only the changed attributes with their new values' do
|
|
577
|
+
instance.text = 'changed text'
|
|
578
|
+
instance.checkbox = true
|
|
579
|
+
|
|
580
|
+
expect(instance.modified_attributes).to eq('checkbox' => true, 'text' => 'changed text')
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
it 'excludes attributes assigned their current value' do
|
|
584
|
+
instance.text = 'Hi there!'
|
|
585
|
+
instance.percent = 21
|
|
586
|
+
|
|
587
|
+
expect(instance.modified_attributes).to eq({})
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
it 'excludes attributes changed and then restored to their original value' do
|
|
591
|
+
instance.text = 'changed text'
|
|
592
|
+
instance.text = 'Hi there!'
|
|
593
|
+
|
|
594
|
+
expect(instance.modified_attributes).to eq({})
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
it 'includes attributes changed to nil' do
|
|
598
|
+
instance.text = nil
|
|
599
|
+
|
|
600
|
+
expect(instance.modified_attributes).to eq('text' => nil)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
it 'includes attributes mutated in place' do
|
|
604
|
+
instance.text << ' Bye!'
|
|
605
|
+
|
|
606
|
+
expect(instance.modified_attributes).to eq('text' => 'Hi there! Bye!')
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
it 'excludes attributes that were not selected' do
|
|
610
|
+
expect(Whizbang.attribute_names).to include('estimated_close_date', 'updated_from')
|
|
611
|
+
expect(instance.modified_attributes.keys).not_to include('estimated_close_date', 'updated_from')
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
it 'includes an unselected attribute once it is assigned' do
|
|
615
|
+
instance.updated_from = 'Rails'
|
|
616
|
+
|
|
617
|
+
expect(instance.modified_attributes).to eq('updated_from' => 'Rails')
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
it 'returns string keys in attribute declaration order' do
|
|
621
|
+
instance.percent = 42
|
|
622
|
+
instance.text = 'changed text'
|
|
623
|
+
instance.checkbox = true
|
|
624
|
+
|
|
625
|
+
expect(instance.modified_attributes.keys).to eq(%w[checkbox text percent])
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
context 'after persisting changes' do
|
|
630
|
+
let(:instance) { Territory.new(id: '1') }
|
|
631
|
+
|
|
632
|
+
it 'returns an empty hash after create' do
|
|
633
|
+
new_instance = Territory.new(name: 'Walter')
|
|
634
|
+
allow(client).to receive(:create!).and_return('1')
|
|
635
|
+
|
|
636
|
+
new_instance.create!
|
|
637
|
+
|
|
638
|
+
expect(new_instance.modified_attributes).to eq({})
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
it 'returns an empty hash after update' do
|
|
642
|
+
allow(client).to receive(:update!).and_return(true)
|
|
643
|
+
|
|
644
|
+
instance.update!(name: 'Walter')
|
|
645
|
+
|
|
646
|
+
expect(instance.modified_attributes).to eq({})
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
it 'returns an empty hash after reload' do
|
|
650
|
+
allow(client).to receive(:query).and_return([Restforce::Mash.new(Id: '1', Name: 'Jeff')])
|
|
651
|
+
instance.name = 'Walter'
|
|
652
|
+
|
|
653
|
+
instance.reload
|
|
654
|
+
|
|
655
|
+
expect(instance.modified_attributes).to eq({})
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
535
660
|
describe 'logger output' do
|
|
536
661
|
let(:instance){ Whizbang.new }
|
|
537
662
|
|
|
@@ -661,4 +786,77 @@ describe ActiveForce::SObject do
|
|
|
661
786
|
end
|
|
662
787
|
end
|
|
663
788
|
end
|
|
789
|
+
|
|
790
|
+
describe ".key_prefix" do
|
|
791
|
+
after { Whizbang.remove_instance_variable(:@key_prefix) if Whizbang.instance_variable_defined?(:@key_prefix) }
|
|
792
|
+
|
|
793
|
+
it "reads the keyPrefix from describe" do
|
|
794
|
+
allow(client).to receive(:describe).with('Whizbang__c').and_return('keyPrefix' => 'a0X')
|
|
795
|
+
expect(Whizbang.key_prefix).to eq 'a0X'
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
it "describes once per class" do
|
|
799
|
+
expect(client).to receive(:describe).once.and_return('keyPrefix' => 'a0X')
|
|
800
|
+
2.times { Whizbang.key_prefix }
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
it "caches a missing prefix as nil without describing again" do
|
|
804
|
+
expect(client).to receive(:describe).once.and_return('keyPrefix' => nil)
|
|
805
|
+
2.times { expect(Whizbang.key_prefix).to be_nil }
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
describe ".delete" do
|
|
810
|
+
before { allow(Whizbang).to receive(:key_prefix).and_return('a0X') }
|
|
811
|
+
|
|
812
|
+
it "deletes a single id and returns the count" do
|
|
813
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(['a0X1'], client).and_return(1)
|
|
814
|
+
expect(Whizbang.delete('a0X1')).to eq 1
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
it "deletes an array of ids" do
|
|
818
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(%w[a0X1 a0X2], client).and_return(2)
|
|
819
|
+
expect(Whizbang.delete(%w[a0X1 a0X2])).to eq 2
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
it "accepts 15 and 18 character ids with the right prefix" do
|
|
823
|
+
ids = %w[a0X000000000001 a0X000000000002AAA]
|
|
824
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(ids, client).and_return(2)
|
|
825
|
+
expect(Whizbang.delete(ids)).to eq 2
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
it "raises before any request when an id belongs to another object" do
|
|
829
|
+
expect(ActiveForce::CompositeCollectionDelete).not_to receive(:call)
|
|
830
|
+
expect { Whizbang.delete(%w[a0X000000000001AAA 003000000000001AAA]) }
|
|
831
|
+
.to raise_error(ArgumentError, /003000000000001AAA.*Whizbang__c ids start with a0X/)
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
it "does not describe when there are no ids" do
|
|
835
|
+
allow(Whizbang).to receive(:key_prefix).and_call_original
|
|
836
|
+
expect(client).not_to receive(:describe)
|
|
837
|
+
expect(Whizbang.delete([nil, ''])).to eq 0
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
it "skips the check when the object has no key prefix" do
|
|
841
|
+
allow(Whizbang).to receive(:key_prefix).and_return(nil)
|
|
842
|
+
expect(ActiveForce::CompositeCollectionDelete).to receive(:call).with(['003000000000001AAA'], client).and_return(1)
|
|
843
|
+
expect(Whizbang.delete('003000000000001AAA')).to eq 1
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
describe ".delete_all" do
|
|
848
|
+
it "is delegated to the query" do
|
|
849
|
+
query = instance_double(ActiveForce::ActiveQuery)
|
|
850
|
+
allow(Whizbang).to receive(:query).and_return(query)
|
|
851
|
+
expect(query).to receive(:delete_all).and_return(3)
|
|
852
|
+
expect(Whizbang.delete_all).to eq 3
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
it "refuses an unscoped delete before any request" do
|
|
856
|
+
expect(client).not_to receive(:query)
|
|
857
|
+
expect(client).not_to receive(:api_delete)
|
|
858
|
+
expect { Whizbang.delete_all }.to raise_error(ActiveForce::UnscopedDelete)
|
|
859
|
+
expect { Whizbang.where(nil).delete_all }.to raise_error(ActiveForce::UnscopedDelete)
|
|
860
|
+
end
|
|
861
|
+
end
|
|
664
862
|
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.1
|
|
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
|
|
@@ -145,6 +159,7 @@ extensions: []
|
|
|
145
159
|
extra_rdoc_files: []
|
|
146
160
|
files:
|
|
147
161
|
- ".circleci/config.yml"
|
|
162
|
+
- ".claude/skills/release/SKILL.md"
|
|
148
163
|
- ".github/ISSUE_TEMPLATE/bug_report.md"
|
|
149
164
|
- ".github/ISSUE_TEMPLATE/feature_request.md"
|
|
150
165
|
- ".gitignore"
|
|
@@ -173,6 +188,7 @@ files:
|
|
|
173
188
|
- lib/active_force/bulk/job_result.rb
|
|
174
189
|
- lib/active_force/bulk/records.rb
|
|
175
190
|
- lib/active_force/composite_batch_query.rb
|
|
191
|
+
- lib/active_force/composite_collection_delete.rb
|
|
176
192
|
- lib/active_force/field.rb
|
|
177
193
|
- lib/active_force/mapping.rb
|
|
178
194
|
- lib/active_force/query.rb
|
|
@@ -196,6 +212,7 @@ files:
|
|
|
196
212
|
- spec/active_force/bulk_spec.rb
|
|
197
213
|
- spec/active_force/callbacks_spec.rb
|
|
198
214
|
- spec/active_force/composite_batch_query_spec.rb
|
|
215
|
+
- spec/active_force/composite_collection_delete_spec.rb
|
|
199
216
|
- spec/active_force/field_spec.rb
|
|
200
217
|
- spec/active_force/mapping_spec.rb
|
|
201
218
|
- spec/active_force/query_notifications_spec.rb
|
|
@@ -247,6 +264,7 @@ test_files:
|
|
|
247
264
|
- spec/active_force/bulk_spec.rb
|
|
248
265
|
- spec/active_force/callbacks_spec.rb
|
|
249
266
|
- spec/active_force/composite_batch_query_spec.rb
|
|
267
|
+
- spec/active_force/composite_collection_delete_spec.rb
|
|
250
268
|
- spec/active_force/field_spec.rb
|
|
251
269
|
- spec/active_force/mapping_spec.rb
|
|
252
270
|
- spec/active_force/query_notifications_spec.rb
|