active_force 0.20.1 → 0.21.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: f1fbcc63ac7ef2ec3ac8efdadd055a61ae8e98fe721473a054d9207d95bc495e
4
- data.tar.gz: 838eebde842f1edcc4b2b2a879b1697aec7439852af0f563b9f6687760b57434
3
+ metadata.gz: acbf91e00afed35fd89a0dc1505ebb40d275883bca6aba9237dad7d554e10b7b
4
+ data.tar.gz: 488fe99179b48383967688ecbfc2c2f93f3f565ec177e972fb6e9ad5e68b5d8e
5
5
  SHA512:
6
- metadata.gz: ec076a1ffc49293809ebb2c66445ca70b1e016c82be03f4bf6803a6482b18d014711caca4112f7875f8a76cf85b27d1381cb8ffd2a749f7c49b21f801f298af6
7
- data.tar.gz: 24378ad367f6ff96edaa88a902c946d5d2f38f71e69d1745cc7d769b0cb1d70dec4752e5825774506985c47202ed15835268b3ad08b1d5d255f6dbfb34b24e44
6
+ metadata.gz: 1b29a58711a0d6b63c82604c1bc2f3bfdc9b06e435468016fa7055bdd8ace5c779d09d4f11fdc32e7292ffd8c41a5784698610c1a6184116044bfa77b1c93cb5
7
+ data.tar.gz: 1bb0165385e5f06c7767d9463842e9daf742e11184e7518b008142162f8fb46bb2d35d5a1ba8f64be702cf169f5d9a6a9bfb3b8a1e83331fc11114c2ae30846a
data/.github/workflows ADDED
@@ -0,0 +1,51 @@
1
+ on:
2
+ pull_request:
3
+ types: [opened, reopened, synchronize]
4
+ pull_request_review_comment:
5
+ types: [created, edited, deleted]
6
+
7
+ name: Metomic Scan
8
+
9
+ jobs:
10
+ scan-secrets:
11
+ name: Scan For Secrets
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: checkout-repo
15
+ uses: actions/checkout@v3
16
+ with:
17
+ ref: ${{ github.event.pull_request.head.sha }}
18
+
19
+ - name: authenticate-with-metomic
20
+ id: authenticate
21
+ continue-on-error: ${{ vars.METOMIC_FAIL_ON_CONNECTION_ERROR != 'TRUE' }}
22
+ run: |
23
+ curl -s --location --request GET '${{ vars.METOMIC_INTEGRATION_ENDPOINT }}/token' --header 'X-API-Key: ${{ secrets.METOMIC_API_TOKEN }}' -o .metomic_token.json
24
+ echo METOMIC_ACCESS_TOKEN=$(cat .metomic_token.json | jq -r -c '.accessToken') >> $GITHUB_OUTPUT
25
+ PASS=$(cat .metomic_token.json | jq -r -c '.accessToken // empty')
26
+ if [ -z "$PASS" ]; then
27
+ cat .metomic_token.json
28
+ echo ""
29
+ echo "FAIL: failed to fetch auth token from Metomic. Ensure required organisation variables / secrets are set correctly. METOMIC_INTEGRATION_ENDPOINT, METOMIC_API_TOKEN"
30
+ echo "The correct values for these variables are available from the installations page of the Metomic dashboard"
31
+ exit 1
32
+ fi
33
+
34
+ - name: checkout-metomic-action
35
+ id: checkout
36
+ uses: actions/checkout@v3
37
+ if: ${{ steps.authenticate.outcome == 'success' }}
38
+ continue-on-error: ${{ vars.METOMIC_FAIL_ON_CONNECTION_ERROR != 'TRUE' }}
39
+ with:
40
+ repository: metomic/metomic-github-integration-action.git
41
+ path: "./.metomic"
42
+ ref: "main"
43
+ token: ${{ steps.authenticate.outputs.METOMIC_ACCESS_TOKEN }}
44
+
45
+ - name: scan
46
+ uses: ./.metomic/.github/actions/scan-secrets
47
+ if: ${{ steps.authenticate.outputs.METOMIC_ACCESS_TOKEN && steps.checkout.outcome == 'success' }}
48
+ with:
49
+ metomic_endpoint: ${{ vars.METOMIC_INTEGRATION_ENDPOINT }}
50
+ metomic_api_token: ${{ secrets.METOMIC_API_TOKEN }}
51
+ head_ref: ${{ github.event.pull_request.head.sha }}
data/.gitignore CHANGED
@@ -15,3 +15,4 @@ spec/reports
15
15
  test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
+ .idea
data/CHANGELOG.md CHANGED
@@ -2,7 +2,18 @@
2
2
 
3
3
  ## Not released
4
4
 
5
+ ## 0.21.0
6
+
7
+ - Uninitialized attributes will ERROR instead of returning as `nil` (https://github.com/Beyond-Finance/active_force/pull/78)
8
+ - Add Range condition support (https://github.com/Beyond-Finance/active_force/pull/87)
9
+ - Use ':decimal' type for Salesforce currency fields in model generation (#88) (https://github.com/Beyond-Finance/active_force/pull/88)
10
+ - Fix includes with has_one associations when no associated record (https://github.com/Beyond-Finance/active_force/pull/83)
11
+ - Raise `UnknownFieldError` if `.where` is given non-existent attribute names (https://github.com/Beyond-Finance/active_force/pull/80)
12
+ - Fix `.update` and `.update!`: include given `nil` valued attributes in request (https://github.com/Beyond-Finance/active_force/pull/79)
13
+ - Change `.first` to not query the API if records have already been retrieved (https://github.com/Beyond-Finance/active_force/pull/77)
14
+
5
15
  ## 0.20.1
16
+
6
17
  - Revert "ActiveForce .first performance enhancement (#73)" (https://github.com/Beyond-Finance/active_force/pull/76)
7
18
 
8
19
  ## 0.20.0
data/README.md CHANGED
@@ -186,6 +186,17 @@ Account.where(contact_by: 'web').or(Account.where(contact_by: 'email'))
186
186
  # OR (contact_by__c = 'email')"
187
187
  ```
188
188
 
189
+ You can use Ranges to specify comparisons:
190
+
191
+ ```ruby
192
+ Account.where(last_activity_date: Date.new(2023, 1, 1)...Date.new(2024, 1, 1))
193
+ .where(annual_revenue: 1_000..)
194
+ #=> this will query "SELECT Id, Name...
195
+ # FROM Account
196
+ # WHERE (LastActivityDate >= 2023-01-01) AND (LastActivityDate < 2024-01-01)
197
+ # AND (AnnualRevenue >= 1000)
198
+ ```
199
+
189
200
  It is also possible to eager load associations:
190
201
 
191
202
  ```ruby
@@ -4,6 +4,13 @@ require 'forwardable'
4
4
 
5
5
  module ActiveForce
6
6
  class PreparedStatementInvalid < ArgumentError; end
7
+
8
+ class UnknownFieldError < StandardError
9
+ def initialize(object, field)
10
+ super("unknown field '#{field}' for #{object.name}")
11
+ end
12
+ end
13
+
7
14
  class RecordNotFound < StandardError
8
15
  attr_reader :table_name, :conditions
9
16
 
@@ -45,9 +52,9 @@ module ActiveForce
45
52
  sfdc_client.query(super.to_s).first.expr0
46
53
  end
47
54
 
48
- def sum field
55
+ def sum(field)
49
56
  raise ArgumentError, 'field is required' if field.blank?
50
- raise ArgumentError, "field '#{field}' does not exist on #{sobject}" unless mappings.key?(field.to_sym)
57
+ raise UnknownFieldError.new(sobject, field) unless mappings.key?(field.to_sym)
51
58
 
52
59
  sfdc_client.query(super(mappings.fetch(field.to_sym)).to_s).first.expr0
53
60
  end
@@ -56,6 +63,10 @@ module ActiveForce
56
63
  limit == 1 ? super.to_a.first : super
57
64
  end
58
65
 
66
+ def first
67
+ super.to_a.first
68
+ end
69
+
59
70
  def not args=nil, *rest
60
71
  return self if args.nil?
61
72
 
@@ -162,16 +173,21 @@ module ActiveForce
162
173
  end
163
174
 
164
175
  def build_conditions_from_hash(hash)
165
- hash.map do |key, value|
166
- applicable_predicate mappings[key], value
176
+ hash.flat_map do |key, value|
177
+ field = mappings[key]
178
+ raise UnknownFieldError.new(sobject, key) if field.blank?
179
+
180
+ applicable_predicates(field, value)
167
181
  end
168
182
  end
169
183
 
170
- def applicable_predicate(attribute, value)
171
- if value.is_a? Array
172
- in_predicate attribute, value
184
+ def applicable_predicates(attribute, value)
185
+ if value.is_a?(Array)
186
+ [in_predicate(attribute, value)]
187
+ elsif value.is_a?(Range)
188
+ range_predicates(attribute, value)
173
189
  else
174
- eq_predicate attribute, value
190
+ [eq_predicate(attribute, value)]
175
191
  end
176
192
  end
177
193
 
@@ -184,6 +200,16 @@ module ActiveForce
184
200
  "#{attribute} = #{enclose_value value}"
185
201
  end
186
202
 
203
+ def range_predicates(attribute, range)
204
+ conditions = []
205
+ conditions << "#{attribute} >= #{enclose_value(range.begin)}" unless range.begin.nil?
206
+ unless range.end.nil?
207
+ operator = range.exclude_end? ? '<' : '<='
208
+ conditions << "#{attribute} #{operator} #{enclose_value(range.end)}"
209
+ end
210
+ conditions
211
+ end
212
+
187
213
  def enclose_value value
188
214
  case value
189
215
  when String
@@ -50,7 +50,11 @@ module ActiveForce
50
50
 
51
51
  class BuildFromArray < AbstractBuildFrom
52
52
  def call
53
- value.map { |mash| association.build(mash, association_mapping) }
53
+ if association.is_a?(HasOneAssociation)
54
+ association.build(value.first, association_mapping)
55
+ else
56
+ value.map { |mash| association.build(mash, association_mapping) }
57
+ end
54
58
  end
55
59
  end
56
60
 
@@ -78,7 +78,15 @@ module ActiveForce
78
78
  end
79
79
 
80
80
  def first
81
- limit 1
81
+ if @records
82
+ clone_and_set_instance_variables(
83
+ size: 1,
84
+ records: [@records.first],
85
+ decorated_records: [@decorated_records&.first]
86
+ )
87
+ else
88
+ clone_and_set_instance_variables(size: 1)
89
+ end
82
90
  end
83
91
 
84
92
  def last(limit = 1)
@@ -126,9 +134,9 @@ module ActiveForce
126
134
 
127
135
  def clone_and_set_instance_variables instance_variable_hash={}
128
136
  clone = self.clone
129
- clone.instance_variable_set(:@decorated_records, nil)
130
- clone.instance_variable_set(:@records, nil)
131
- instance_variable_hash.each { |k,v| clone.instance_variable_set("@#{k.to_s}", v) }
137
+ { decorated_records: nil, records: nil }
138
+ .merge(instance_variable_hash)
139
+ .each { |k,v| clone.instance_variable_set("@#{k.to_s}", v) }
132
140
  clone
133
141
  end
134
142
  end
@@ -7,6 +7,7 @@ require 'yaml'
7
7
  require 'forwardable'
8
8
  require 'logger'
9
9
  require 'restforce'
10
+ require 'active_model/attribute/uninitialized_value'
10
11
 
11
12
  module ActiveForce
12
13
  class RecordInvalid < StandardError;end
@@ -34,8 +35,24 @@ module ActiveForce
34
35
  def_delegators :query, :not, :or, :where, :first, :last, :all, :find, :find!, :find_by, :find_by!, :sum, :count, :includes, :limit, :order, :select, :none
35
36
  def_delegators :mapping, :table, :table_name, :custom_table?, :mappings
36
37
 
38
+ def update(id, attributes)
39
+ prepare_for_update(id, attributes).update
40
+ end
41
+
42
+ def update!(id, attributes)
43
+ prepare_for_update(id, attributes).update!
44
+ end
45
+
37
46
  private
38
47
 
48
+ def prepare_for_update(id, attributes)
49
+ new(attributes.merge(id: id)).tap do |obj|
50
+ attributes.each do |name, value|
51
+ obj.public_send("#{name}_will_change!") if value.nil?
52
+ end
53
+ end
54
+ end
55
+
39
56
  ###
40
57
  # Provide each subclass with a default id field. Can be overridden
41
58
  # in the subclass if needed
@@ -64,6 +81,9 @@ module ActiveForce
64
81
  def self.build mash, association_mapping={}
65
82
  return unless mash
66
83
  sobject = new
84
+
85
+ attributes_not_selected = sobject.class.fields.reject{|key| mash.keys.include?(key)}
86
+ sobject.uninitialize_attributes(attributes_not_selected)
67
87
  sobject.build_attributes = mash[:build_attributes] || mash
68
88
  sobject.run_callbacks(:build) do
69
89
  mash.each do |column, value|
@@ -110,6 +130,17 @@ module ActiveForce
110
130
  self
111
131
  end
112
132
 
133
+ def uninitialize_attributes(attrs)
134
+ return if attrs.blank?
135
+ self.instance_variable_get(:@attributes).instance_variable_get(:@attributes).each do |key, value|
136
+ if attrs.include?(self.mappings.dig(value.name.to_sym))
137
+ self.instance_variable_get(:@attributes).instance_variable_get(:@attributes)[key] = ActiveModel::Attribute::UninitializedValue.new(value.name, value.type)
138
+ else
139
+ key
140
+ end
141
+ end
142
+ end
143
+
113
144
  def create
114
145
  create!
115
146
  rescue Faraday::ClientError, RecordInvalid => error
@@ -131,14 +162,6 @@ module ActiveForce
131
162
  new(args).create!
132
163
  end
133
164
 
134
- def self.update(id, attributes)
135
- new(attributes.merge(id: id)).update
136
- end
137
-
138
- def self.update!(id, attributes)
139
- new(attributes.merge(id: id)).update!
140
- end
141
-
142
165
  def save!
143
166
  run_callbacks :save do
144
167
  if persisted?
@@ -183,18 +206,12 @@ module ActiveForce
183
206
  self
184
207
  end
185
208
 
186
- def write_value key, value, association_mapping = {}
187
- if association = self.class.find_association(key.to_sym)
188
- field = association.relation_name
189
- value = Association::RelationModelBuilder.build(association, value, association_mapping)
190
- elsif key.to_sym.in?(mappings.keys)
191
- # key is a field name
192
- field = key
209
+ def write_value(key, value, association_mapping = {})
210
+ if (association = self.class.find_association(key.to_sym))
211
+ write_association_value(association, value, association_mapping)
193
212
  else
194
- # Assume key is an SFDC column
195
- field = mappings.key(key)
213
+ write_field_value(key, value)
196
214
  end
197
- send "#{field}=", value if field && respond_to?(field)
198
215
  end
199
216
 
200
217
  def [](name)
@@ -205,7 +222,7 @@ module ActiveForce
205
222
  send("#{name.to_sym}=", value)
206
223
  end
207
224
 
208
- private
225
+ private
209
226
 
210
227
  def validate!
211
228
  unless valid?
@@ -215,6 +232,21 @@ module ActiveForce
215
232
  end
216
233
  end
217
234
 
235
+ def write_association_value(association, value, association_mapping)
236
+ association_cache[association.relation_name] = Association::RelationModelBuilder.build(association, value,
237
+ association_mapping)
238
+ end
239
+
240
+ def write_field_value(field_key, value)
241
+ field = if mappings.key?(field_key.to_sym)
242
+ field_key
243
+ else
244
+ mappings.key(field_key)
245
+ end
246
+
247
+ send("#{field}=", value) if field && respond_to?(field)
248
+ end
249
+
218
250
  def handle_save_error error
219
251
  return false if error.class == RecordInvalid
220
252
  logger_output __method__, error, attributes
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveForce
4
- VERSION = '0.20.1'
4
+ VERSION = '0.21.0'
5
5
  end
@@ -0,0 +1,12 @@
1
+ require 'active_model'
2
+
3
+ module ActiveModel
4
+ class Attribute
5
+ class UninitializedValue < Uninitialized # :nodoc:
6
+
7
+ def value
8
+ raise ActiveModel::MissingAttributeError, "missing attribute: #{name}"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -9,7 +9,7 @@ module ActiveForce
9
9
  'boolean' => :boolean,
10
10
  'double' => :float,
11
11
  'percentage' => :float,
12
- 'currency' => :float,
12
+ 'currency' => :decimal,
13
13
  'date' => :date,
14
14
  'datetime' => :datetime,
15
15
  'int' => :integer,
@@ -1,12 +1,17 @@
1
1
  require 'spec_helper'
2
2
 
3
+ class TestSObject < ActiveForce::SObject
4
+ def self.decorate(records)
5
+ records
6
+ end
7
+ end
8
+
3
9
  describe ActiveForce::ActiveQuery do
4
10
  let(:sobject) do
5
- double("sobject", {
6
- table_name: "table_name",
7
- fields: [],
8
- mappings: mappings
9
- })
11
+ class_double(
12
+ TestSObject,
13
+ { table_name: 'table_name', fields: [], mappings: mappings, name: 'TableName' }
14
+ )
10
15
  end
11
16
  let(:mappings){ { id: "Id", field: "Field__c", other_field: "Other_Field" } }
12
17
  let(:client) { double('client', query: nil) }
@@ -148,6 +153,74 @@ describe ActiveForce::ActiveQuery do
148
153
  expect(new_query.to_s).to end_with("(Field__c = NULL)")
149
154
  end
150
155
 
156
+ describe 'range filter' do
157
+ def check_endless(query, start, field: 'Field__c')
158
+ expect(query.to_s).to end_with("(#{field} >= #{start})")
159
+ end
160
+
161
+ def check_beginless_inclusive(query, finish, field: 'Field__c')
162
+ expect(query.to_s).to end_with("(#{field} <= #{finish})")
163
+ end
164
+
165
+ def check_beginless_exclusive(query, finish, field: 'Field__c')
166
+ expect(query.to_s).to end_with("(#{field} < #{finish})")
167
+ end
168
+
169
+ def check_inclusive(query, start, finish, field: 'Field__c')
170
+ expect(query.to_s).to end_with("(#{field} >= #{start}) AND (#{field} <= #{finish})")
171
+ end
172
+
173
+ def check_exclusive(query, start, finish, field: 'Field__c')
174
+ expect(query.to_s).to end_with("(#{field} >= #{start}) AND (#{field} < #{finish})")
175
+ end
176
+
177
+ def check_ranges(base_query, start, finish, &format_block)
178
+ formatted_start = format_block&.call(start) || start.to_s
179
+ formatted_finish = format_block&.call(finish) || finish.to_s
180
+ check_endless(base_query.where(field: start..), formatted_start)
181
+ check_beginless_inclusive(base_query.where(field: ..finish), formatted_finish)
182
+ check_beginless_exclusive(base_query.where(field: ...finish), formatted_finish)
183
+ check_inclusive(base_query.where(field: start..finish), formatted_start, formatted_finish)
184
+ check_exclusive(base_query.where(field: start...finish), formatted_start, formatted_finish)
185
+ end
186
+
187
+ it 'renders with Dates' do
188
+ check_ranges(active_query, Date.new(2024, 2, 2), Date.new(2024, 2, 28))
189
+ end
190
+
191
+ it 'renders with DateTimes' do
192
+ check_ranges(active_query, DateTime.new(2024, 1, 31, 1, 2, 3), DateTime.new(2024, 1, 31, 1, 2, 4))
193
+ end
194
+
195
+ it 'renders with Times' do
196
+ check_ranges(active_query, Time.current, Time.current + 1.hour, &:iso8601)
197
+ end
198
+
199
+ it 'renders with Strings' do
200
+ check_ranges(active_query, 'a', 'z') { |x| "'#{x}'"}
201
+ end
202
+
203
+ it 'renders with Integers' do
204
+ check_ranges(active_query, 1, 99)
205
+ end
206
+
207
+ it 'renders with Floats' do
208
+ check_ranges(active_query, 0.5, 100.89)
209
+ end
210
+
211
+ it 'renders with BigDecimal' do
212
+ check_ranges(active_query, BigDecimal('0.888'), BigDecimal('11.0003'))
213
+ end
214
+
215
+ it 'composes with other conditions' do
216
+ query = active_query.where(id: 'id1'.., field: 1..99, other_field: 'a')
217
+ .not(id: 'id2')
218
+ expect(query.to_s).to end_with(
219
+ "(Id >= 'id1') AND (Field__c >= 1) AND (Field__c <= 99) AND (Other_Field = 'a') AND (NOT ((Id = 'id2')))"
220
+ )
221
+ end
222
+ end
223
+
151
224
  describe 'bind parameters' do
152
225
  let(:mappings) do
153
226
  super().merge({
@@ -296,6 +369,13 @@ describe ActiveForce::ActiveQuery do
296
369
  end
297
370
  end
298
371
  end
372
+
373
+ context 'when given attributes Hash with fields that do not exist on the SObject' do
374
+ it 'raises UnknownFieldError' do
375
+ expect { active_query.where(xyz: 1) }
376
+ .to raise_error(ActiveForce::UnknownFieldError, /unknown field 'xyz' for #{sobject.name}/i)
377
+ end
378
+ end
299
379
  end
300
380
 
301
381
  describe '#not' do
@@ -329,6 +409,11 @@ describe ActiveForce::ActiveQuery do
329
409
  new_query = active_query.find_by field: 123
330
410
  expect(new_query).to be_nil
331
411
  end
412
+
413
+ it 'should raise UnknownFieldError if given invalid field' do
414
+ expect { active_query.find_by(invalid: true) }
415
+ .to raise_error(ActiveForce::UnknownFieldError, /unknown field 'invalid' for #{sobject.name}/i)
416
+ end
332
417
  end
333
418
 
334
419
  describe '#find_by!' do
@@ -337,6 +422,11 @@ describe ActiveForce::ActiveQuery do
337
422
  expect { active_query.find_by!(field: 123) }
338
423
  .to raise_error(ActiveForce::RecordNotFound, "Couldn't find #{sobject.table_name} with {:field=>123}")
339
424
  end
425
+
426
+ it 'should raise UnknownFieldError if given invalid field' do
427
+ expect { active_query.find_by!(invalid: true) }
428
+ .to raise_error(ActiveForce::UnknownFieldError, /unknown field 'invalid' for #{sobject.name}/i)
429
+ end
340
430
  end
341
431
 
342
432
  describe '#find!' do
@@ -463,4 +553,22 @@ describe ActiveForce::ActiveQuery do
463
553
  end
464
554
 
465
555
  end
556
+
557
+ describe '#first' do
558
+ before do
559
+ allow(client).to receive(:query).and_return(api_result)
560
+ api_result.each do |instance|
561
+ allow(active_query).to receive(:build).with(instance, {}).and_return(double(:sobject, id: instance['Id']))
562
+ end
563
+ end
564
+
565
+ it 'returns a single record when the api was already queried' do
566
+ active_query.to_a # this will simulate the api call as to_a executes the query and populates the records
567
+ expect(active_query.first.id).to eq("0000000000AAAAABBB")
568
+ end
569
+
570
+ it 'returns a single record when the api was not already queried' do
571
+ expect(active_query.first.id).to eq("0000000000AAAAABBB")
572
+ end
573
+ end
466
574
  end
@@ -70,6 +70,36 @@ module ActiveForce
70
70
  end
71
71
  end
72
72
 
73
+ context 'with a restforce collection value' do
74
+ let(:value) do
75
+ build_restforce_collection([
76
+ build_restforce_sobject('Id' => 'first'),
77
+ build_restforce_sobject('Id' => 'second')
78
+ ])
79
+ end
80
+
81
+ it 'returns a child for the first value' do
82
+ actual = instance.build_relation_model
83
+ expect(actual).to be_a(HasOneChild)
84
+ expect(actual.id).to eq('first')
85
+ end
86
+ end
87
+
88
+ context 'with an array value' do
89
+ let(:value) do
90
+ [
91
+ build_restforce_sobject('Id' => 'first'),
92
+ build_restforce_sobject('Id' => 'second')
93
+ ]
94
+ end
95
+
96
+ it 'returns a child for the first value' do
97
+ actual = instance.build_relation_model
98
+ expect(actual).to be_a(HasOneChild)
99
+ expect(actual.id).to eq('first')
100
+ end
101
+ end
102
+
73
103
  context 'without a value' do
74
104
  let(:value){ nil }
75
105
 
@@ -165,6 +165,22 @@ describe ActiveForce::Query do
165
165
  expect(query.to_s).to eq "SELECT Id, name, etc FROM table_name"
166
166
  expect(new_query.to_s).to eq 'SELECT Id, name, etc FROM table_name LIMIT 1'
167
167
  end
168
+
169
+ it "does not query if records have already been fetched" do
170
+ query = ActiveForce::Query.new 'table_name'
171
+ query.instance_variable_set(:@records, %w[foo bar])
172
+ query.instance_variable_set(:@decorated_records, %w[foo bar])
173
+ expect(query).not_to receive(:clone_and_set_instance_variables).with(size: 1)
174
+ expect(query).to receive(:clone_and_set_instance_variables).with(size: 1, records: ['foo'], decorated_records: ['foo'])
175
+ query.first
176
+ end
177
+
178
+ it 'queries the api if it has not been queried yet' do
179
+ query = ActiveForce::Query.new 'table_name'
180
+ query.instance_variable_set(:@records, nil)
181
+ expect(query).to receive(:clone_and_set_instance_variables).with(size: 1)
182
+ query.first
183
+ end
168
184
  end
169
185
 
170
186
  describe '.last' do
@@ -301,6 +301,45 @@ module ActiveForce
301
301
  end
302
302
  end
303
303
 
304
+ context 'when query returns nil for associated record' do
305
+ let(:response) do
306
+ [build_restforce_sobject({ 'Id' => '123', 'Membership__r' => nil })]
307
+ end
308
+
309
+ before do
310
+ allow(client).to receive(:query).and_return(response)
311
+ end
312
+
313
+ it 'the association method returns nil without making another request' do
314
+ member = ClubMember.includes(:membership).where(id: '123').first
315
+ membership = member.membership
316
+ expect(membership).to be_nil
317
+ expect(client).to have_received(:query).once
318
+ end
319
+ end
320
+ end
321
+
322
+ context 'when query returns an associated record' do
323
+ let(:response) do
324
+ [
325
+ build_restforce_sobject(
326
+ {
327
+ 'Id' => '123',
328
+ 'Membership__r' => build_restforce_collection([build_restforce_sobject({ 'Id' => '33' })])
329
+ }
330
+ )
331
+ ]
332
+ end
333
+
334
+ before do
335
+ allow(client).to receive(:query).and_return(response)
336
+ end
337
+
338
+ it 'the association method returns the record without making another request' do
339
+ member = ClubMember.includes(:membership).where(id: '123').first
340
+ expect(member.membership.id).to eq('33')
341
+ expect(client).to have_received(:query).once
342
+ end
304
343
  end
305
344
 
306
345
  context 'when invalid associations are passed' do
@@ -0,0 +1,40 @@
1
+ require 'spec_helper'
2
+
3
+ module ActiveForce
4
+ describe SObject do
5
+ let(:client){ double "client" }
6
+
7
+ before do
8
+ ActiveForce.sfdc_client = client
9
+ end
10
+
11
+ describe '.select' do
12
+ it 'has correct fields in query' do
13
+ query = Territory.select(:name, :id)
14
+ expect(query.fields).to eq(["Name", "Id"])
15
+ end
16
+
17
+ context 'when getting the value of an uninitialized attribute' do
18
+ let(:territory) { Territory.select(:id).first }
19
+ let(:response) do
20
+ [build_restforce_sobject({
21
+ "Id" => "123",
22
+ "Quota__c" => "321",
23
+ })]
24
+ end
25
+
26
+ before do
27
+ allow(client).to receive(:query).once.and_return response
28
+ end
29
+
30
+ it 'raises missing attribute error if uninitialized variable is called' do
31
+ expect{territory.name}.to raise_error(ActiveModel::MissingAttributeError)
32
+ end
33
+
34
+ it 'returns SObjects with Uninitialized Value' do
35
+ expect(territory.instance_variable_get(:@attributes)["name"]).to be_an_instance_of(ActiveModel::Attribute::UninitializedValue)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -321,6 +321,15 @@ describe ActiveForce::SObject do
321
321
  .and_return(true)
322
322
  Whizbang.update('12345678', text: 'my text')
323
323
  end
324
+
325
+ it 'includes given nil values in the request' do
326
+ allow(client).to receive(:update!).and_return(true)
327
+ Whizbang.update('test123', text: nil, date: nil)
328
+ expect(client).to have_received(:update!).with(
329
+ Whizbang.table_name,
330
+ { 'Id' => 'test123', 'Text_Label' => nil, 'Date_Label' => nil, 'Updated_From__c' => 'Rails' }
331
+ )
332
+ end
324
333
  end
325
334
 
326
335
  describe 'self.update!' do
@@ -330,6 +339,15 @@ describe ActiveForce::SObject do
330
339
  .and_return(true)
331
340
  Whizbang.update('123456789', text: 'some other text')
332
341
  end
342
+
343
+ it 'includes given nil values in the request' do
344
+ allow(client).to receive(:update!).and_return(true)
345
+ Whizbang.update!('test123', text: nil, date: nil)
346
+ expect(client).to have_received(:update!).with(
347
+ Whizbang.table_name,
348
+ { 'Id' => 'test123', 'Text_Label' => nil, 'Date_Label' => nil, 'Updated_From__c' => 'Rails' }
349
+ )
350
+ end
333
351
  end
334
352
  end
335
353
 
@@ -368,9 +386,9 @@ describe ActiveForce::SObject do
368
386
  expect { Whizbang.sum(nil) }.to raise_error(ArgumentError, 'field is required')
369
387
  end
370
388
 
371
- it 'raises ArgumentError if given invalid field' do
389
+ it 'raises UnknownFieldError if given invalid field' do
372
390
  expect { Whizbang.sum(:invalid) }
373
- .to raise_error(ArgumentError, /field 'invalid' does not exist on Whizbang/i)
391
+ .to raise_error(ActiveForce::UnknownFieldError, /unknown field 'invalid' for Whizbang/i)
374
392
  end
375
393
 
376
394
  it 'sends the correct query to the client' do
@@ -401,6 +419,11 @@ describe ActiveForce::SObject do
401
419
  expect(client).to receive(:query).with("SELECT #{Whizbang.fields.join ', '} FROM Whizbang__c WHERE (Id = 123) AND (Text_Label = 'foo') LIMIT 1")
402
420
  Whizbang.find_by id: 123, text: "foo"
403
421
  end
422
+
423
+ it 'raises UnknownFieldError if given invalid field' do
424
+ expect { Whizbang.find_by(xyz: 1) }
425
+ .to raise_error(ActiveForce::UnknownFieldError, /unknown field 'xyz' for Whizbang/)
426
+ end
404
427
  end
405
428
 
406
429
  describe "#find_by!" do
@@ -408,10 +431,16 @@ describe ActiveForce::SObject do
408
431
  expect(client).to receive(:query).with("SELECT #{Whizbang.fields.join ', '} FROM Whizbang__c WHERE (Id = 123) AND (Text_Label = 'foo') LIMIT 1").and_return([Restforce::Mash.new(Id: 123, text: 'foo')])
409
432
  Whizbang.find_by! id: 123, text: "foo"
410
433
  end
434
+
411
435
  it "raises if nothing found" do
412
436
  expect(client).to receive(:query).with("SELECT #{Whizbang.fields.join ', '} FROM Whizbang__c WHERE (Id = 123) AND (Text_Label = 'foo') LIMIT 1")
413
437
  expect { Whizbang.find_by! id: 123, text: "foo" }.to raise_error(ActiveForce::RecordNotFound)
414
438
  end
439
+
440
+ it 'raises UnknownFieldError if given invalid field' do
441
+ expect { Whizbang.find_by!(xyz: 1) }
442
+ .to raise_error(ActiveForce::UnknownFieldError, /unknown field 'xyz' for Whizbang/)
443
+ end
415
444
  end
416
445
 
417
446
  describe '.find!' do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_force
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.1
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eloy Espinaco
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2023-11-29 00:00:00.000000000 Z
14
+ date: 2024-02-15 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: activemodel
@@ -134,6 +134,7 @@ files:
134
134
  - ".circleci/config.yml"
135
135
  - ".github/ISSUE_TEMPLATE/bug_report.md"
136
136
  - ".github/ISSUE_TEMPLATE/feature_request.md"
137
+ - ".github/workflows"
137
138
  - ".gitignore"
138
139
  - ".mailmap"
139
140
  - ".rspec"
@@ -166,6 +167,7 @@ files:
166
167
  - lib/active_force/standard_types.rb
167
168
  - lib/active_force/table.rb
168
169
  - lib/active_force/version.rb
170
+ - lib/active_model/attribute/uninitialized_value.rb
169
171
  - lib/active_model/type/salesforce/multipicklist.rb
170
172
  - lib/active_model/type/salesforce/percent.rb
171
173
  - lib/generators/active_force/model/USAGE
@@ -183,6 +185,7 @@ files:
183
185
  - spec/active_force/mapping_spec.rb
184
186
  - spec/active_force/query_spec.rb
185
187
  - spec/active_force/sobject/includes_spec.rb
188
+ - spec/active_force/sobject/select_spec.rb
186
189
  - spec/active_force/sobject/table_name_spec.rb
187
190
  - spec/active_force/sobject_spec.rb
188
191
  - spec/active_force/table_spec.rb
@@ -216,7 +219,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
219
  - !ruby/object:Gem::Version
217
220
  version: '0'
218
221
  requirements: []
219
- rubygems_version: 3.3.26
222
+ rubygems_version: 3.4.10
220
223
  signing_key:
221
224
  specification_version: 4
222
225
  summary: Help you implement models persisting on Sales Force within Rails using RESTForce
@@ -233,6 +236,7 @@ test_files:
233
236
  - spec/active_force/mapping_spec.rb
234
237
  - spec/active_force/query_spec.rb
235
238
  - spec/active_force/sobject/includes_spec.rb
239
+ - spec/active_force/sobject/select_spec.rb
236
240
  - spec/active_force/sobject/table_name_spec.rb
237
241
  - spec/active_force/sobject_spec.rb
238
242
  - spec/active_force/table_spec.rb