active_force 0.20.1 → 0.21.0

Sign up to get free protection for your applications and to get access to all the features.
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