active_force 0.26.0 → 0.27.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: 657c5d9e979b06641cae8f6734a9a0371669dcc3fca816e89c0a23fca20537fb
4
- data.tar.gz: c6e3646ea293a22d459faca15f8fbf9b16cbaf4482b7c38dc4e135dac22da6e7
3
+ metadata.gz: 1c4cc417eaaf0ad9a412c1a39374d80e4a723d0f8d6184eba87c09371e3ba56a
4
+ data.tar.gz: d8cdb8bc16ab43f8d1fbe4840788e412d5aa0ad3a25734cbc2b85d7138626c75
5
5
  SHA512:
6
- metadata.gz: b9a1fb7822ad1950f32fc86e20e9e00cf29a5f471b2792eda9fe8ae9aae1db6f2b1817ac8fcf6fc9b0ec74ee351e62befb0aab8a6e83a7a5ec09a3761e656f85
7
- data.tar.gz: 7f1cb6448305cef1fd185cce72571f1eff55aa3115444edf215dd031711fe6027b51922832392eb02a1e60a82b9d77616bffe271aad638344686122bdeae91c6
6
+ metadata.gz: c005126087818d2afa9ef019e537ae5920fea69f9275ba74293cbe97ea9335cfb62da1deedd1c58e707536357f45cf322dcfa48d38b6d007e89f5210dfaf86b2
7
+ data.tar.gz: 43623c0cda29edc52f3ccaddad50010a76d88417d6ebcbe5d80c2bd838518eddbaef217a41ec14c0e372ba50dbe8cad4cf3eaebe1f1f0998734bde19a4251d63
data/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Not released
4
4
 
5
+ ## 0.27.0
6
+ - Emit `query.active_force` notifications for logical query executions, including aggregates and composite batch queries, with SOQL, model, client identity, transport, and standard exception payloads. Cache hits count as logical queries, not API calls. (https://github.com/Beyond-Finance/active_force/pull/117)
7
+
5
8
  ## 0.26.0
6
9
  - Add ability to query using Composite Batch Api (https://github.com/Beyond-Finance/active_force/pull/114)
7
10
 
data/README.md CHANGED
@@ -293,6 +293,47 @@ When using rails, you can generate a model with all the fields you have on your
293
293
 
294
294
  rails g active_force:model <table name>
295
295
 
296
+ ### Query notifications
297
+
298
+ ActiveForce emits `query.active_force` through `ActiveSupport::Notifications` for
299
+ logical query executions, including `count`, `sum`, and composite batch queries.
300
+ Requiring ActiveForce does not install a logger or query detector.
301
+
302
+ ```ruby
303
+ subscriber = ActiveSupport::Notifications.subscribe('query.active_force') do |*args|
304
+ event = ActiveSupport::Notifications::Event.new(*args)
305
+ # Inspect event.duration and event.payload in your development/test tooling.
306
+ end
307
+
308
+ # When no longer needed:
309
+ ActiveSupport::Notifications.unsubscribe(subscriber)
310
+ ```
311
+
312
+ Subscriber callbacks run synchronously and are not isolated from the query
313
+ itself: if your subscriber raises, that exception replaces the query's real
314
+ result or real exception (this is standard `ActiveSupport::Notifications`
315
+ behavior, the same as Rails' own `sql.active_record`). Wrap any subscriber
316
+ logic that could fail — network calls, external clients — in its own `rescue`.
317
+
318
+ The payload contains:
319
+
320
+ - `soql`: the executed SOQL string, including raw values. Treat it as sensitive;
321
+ only log it deliberately with appropriate data handling.
322
+ - `model`: the queried `ActiveForce::SObject` class.
323
+ - `client_id`: the client's Ruby `object_id` (process-local identity, not credentials).
324
+ - `transport`: `:query` or `:composite_batch`. Aggregates retain direct `:query`
325
+ transport regardless of the composite threshold.
326
+
327
+ Failed executions include ActiveSupport's standard `exception` and
328
+ `exception_object` fields and still raise the original exception. Consumers
329
+ counting successful queries should exclude those events.
330
+
331
+ These are **logical queries, not Salesforce API-call counts**. Restforce HTTP-cache
332
+ hits still count; no cache-hit marker is exposed. Internal retries and later
333
+ pagination do not emit additional events. Lazy query construction and repeated
334
+ access to loaded records emit none. Instrumentation does not enumerate results.
335
+ Direct Restforce calls, SOSL, writes, and Bulk APIs are outside this event.
336
+
296
337
  ## Contributing
297
338
 
298
339
  1. Fork it
@@ -308,3 +349,7 @@ When using rails, you can generate a model with all the fields you have on your
308
349
  [3]: https://github.com/bkeepers/dotenv
309
350
  [4]: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/bulk_api_2_0.htm
310
351
 
352
+
353
+ ## License
354
+
355
+ [MIT](LICENSE.txt).
@@ -52,14 +52,14 @@ module ActiveForce
52
52
  alias_method :all, :to_a
53
53
 
54
54
  def count
55
- sfdc_client.query(super.to_s).first.expr0
55
+ execute_query(super.to_s).first.expr0
56
56
  end
57
57
 
58
58
  def sum(field)
59
59
  raise ArgumentError, 'field is required' if field.blank?
60
60
  raise UnknownFieldError.new(sobject, field) unless mappings.key?(field.to_sym)
61
61
 
62
- sfdc_client.query(super(mappings.fetch(field.to_sym)).to_s).first.expr0
62
+ execute_query(super(mappings.fetch(field.to_sym)).to_s).first.expr0
63
63
  end
64
64
 
65
65
  def limit limit
@@ -248,10 +248,20 @@ module ActiveForce
248
248
  def result
249
249
  soql = self.to_s
250
250
 
251
- if soql.length >= ActiveForce.composite_batch_query_threshold
252
- CompositeBatchQuery.call(soql, sfdc_client)
253
- else
254
- sfdc_client.query(soql)
251
+ transport = soql.length >= ActiveForce.composite_batch_query_threshold ? :composite_batch : :query
252
+ execute_query(soql, transport)
253
+ end
254
+
255
+ def execute_query(soql, transport = :query)
256
+ client = sfdc_client
257
+ payload = { soql: soql, model: sobject, client_id: client.object_id, transport: transport }
258
+
259
+ ActiveSupport::Notifications.instrument('query.active_force', payload) do
260
+ if transport == :composite_batch
261
+ CompositeBatchQuery.call(soql, client)
262
+ else
263
+ client.query(soql)
264
+ end
255
265
  end
256
266
  end
257
267
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveForce
4
- VERSION = '0.26.0'
4
+ VERSION = '0.27.0'
5
5
  end
@@ -0,0 +1,164 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'query.active_force notifications' do
4
+ let(:model) do
5
+ Class.new(ActiveForce::SObject) do
6
+ self.table_name = 'Widgets'
7
+ field :id, from: 'Id'
8
+ field :amount, from: 'Amount__c'
9
+ end
10
+ end
11
+ let(:client) { double('client') }
12
+ let(:query) { ActiveForce::ActiveQuery.new(model) }
13
+ let(:events) { [] }
14
+
15
+ around do |example|
16
+ subscriber = ActiveSupport::Notifications.subscribe('query.active_force') do |*args|
17
+ events << ActiveSupport::Notifications::Event.new(*args)
18
+ end
19
+ example.run
20
+ ensure
21
+ ActiveSupport::Notifications.unsubscribe(subscriber)
22
+ end
23
+
24
+ before do
25
+ allow(model).to receive(:sfdc_client).and_return(client)
26
+ end
27
+
28
+ it 'emits the SOQL, model, nonsecret client identity and direct transport without enumerating the result' do
29
+ result = double('unloaded collection')
30
+ expect(client).to receive(:query).with(query.to_s).and_return(result)
31
+ expect(query.send(:result)).to equal(result)
32
+ expect(events.size).to eq(1)
33
+ expect(events.first.payload).to eq(soql: query.to_s, model: model,
34
+ client_id: client.object_id, transport: :query)
35
+ end
36
+
37
+ it 'does not emit for lazy construction or SOQL rendering' do
38
+ query.where(id: 'fake-id').select(:id).to_s
39
+ expect(events).to be_empty
40
+ end
41
+
42
+ it 'does not emit again when loaded records are reused' do
43
+ expect(client).to receive(:query).once.and_return([])
44
+ result = query.to_a
45
+ expect(query.to_a).to equal(result)
46
+ expect(events.size).to eq(1)
47
+ end
48
+
49
+ it 'counts separate logical queries even when a client returns the same cached result' do
50
+ cached_result = []
51
+ expect(client).to receive(:query).twice.and_return(cached_result)
52
+ 2.times { ActiveForce::ActiveQuery.new(model).to_a }
53
+ expect(events.size).to eq(2)
54
+ expect(events.map(&:payload).uniq.size).to eq(1)
55
+ expect(events.first.payload).not_to have_key(:cache_hit)
56
+ end
57
+
58
+ it 'does not emit for pages retrieved while enumerating the returned collection' do
59
+ collection = double('paginated collection')
60
+ expect(client).to receive(:query).once.and_return(collection)
61
+ expect(collection).to receive(:to_a) do
62
+ client.get('/fake/next-page')
63
+ []
64
+ end
65
+ expect(client).to receive(:get).with('/fake/next-page')
66
+ query.to_a
67
+ expect(events.size).to eq(1)
68
+ end
69
+
70
+ it 'emits once when the client retries internally' do
71
+ attempts = 0
72
+ allow(client).to receive(:query) do
73
+ begin
74
+ attempts += 1
75
+ raise IOError if attempts == 1
76
+ []
77
+ rescue IOError
78
+ retry
79
+ end
80
+ end
81
+ query.to_a
82
+ expect(attempts).to eq(2)
83
+ expect(events.size).to eq(1)
84
+ end
85
+
86
+ it 'distinguishes client instances' do
87
+ other_client = double('other client', query: [])
88
+ allow(client).to receive(:query).and_return([])
89
+ query.to_a
90
+ allow(model).to receive(:sfdc_client).and_return(other_client)
91
+ ActiveForce::ActiveQuery.new(model).to_a
92
+ expect(events.map { |event| event.payload[:client_id] }).to eq([client.object_id, other_client.object_id])
93
+ end
94
+
95
+ [:query, :composite_batch].each do |transport|
96
+ context "with #{transport} transport" do
97
+ before do
98
+ allow(ActiveForce).to receive(:composite_batch_query_threshold).and_return(transport == :query ? 100_000 : 0)
99
+ end
100
+
101
+ it 'preserves the result and emits one event' do
102
+ result = double('unloaded result')
103
+ if transport == :query
104
+ expect(client).to receive(:query).with(query.to_s).and_return(result)
105
+ else
106
+ expect(client).not_to receive(:query)
107
+ expect(ActiveForce::CompositeBatchQuery).to receive(:call).with(query.to_s, client).and_return(result)
108
+ end
109
+ expect(query.send(:result)).to equal(result)
110
+ expect(events.size).to eq(1)
111
+ expect(events.first.payload[:transport]).to eq(transport)
112
+ end
113
+
114
+ it 'preserves the original exception and standard notification error payload' do
115
+ error = IOError.new('fake failure')
116
+ if transport == :query
117
+ allow(client).to receive(:query).and_raise(error)
118
+ else
119
+ allow(ActiveForce::CompositeBatchQuery).to receive(:call).and_raise(error)
120
+ end
121
+ expect { query.to_a }.to raise_error { |raised| expect(raised).to equal(error) }
122
+ expect(events.size).to eq(1)
123
+ expect(events.first.payload).to include(exception: ['IOError', 'fake failure'], exception_object: error,
124
+ transport: transport, soql: query.to_s, model: model, client_id: client.object_id)
125
+ expect(query.loaded?).to be false
126
+ end
127
+ end
128
+ end
129
+
130
+ [:count, :sum].each do |operation|
131
+ context "##{operation}" do
132
+ let(:arguments) { operation == :sum ? [:amount] : [] }
133
+
134
+ before do
135
+ allow(ActiveForce).to receive(:composite_batch_query_threshold).and_return(0)
136
+ end
137
+
138
+ it 'retains direct query transport and the aggregate value even above the composite threshold' do
139
+ expect(ActiveForce::CompositeBatchQuery).not_to receive(:call)
140
+ aggregate = double('aggregate', expr0: 42)
141
+ expect(client).to receive(:query).and_return([aggregate])
142
+ expect(query.public_send(operation, *arguments)).to eq(42)
143
+ expect(events.size).to eq(1)
144
+ expression = operation == :sum ? 'sum(Amount__c)' : 'count(Id)'
145
+ expect(events.first.payload).to eq(soql: "SELECT #{expression} FROM Widgets", model: model,
146
+ client_id: client.object_id, transport: :query)
147
+ end
148
+
149
+ it 'reports failed aggregate executions without replacing the exception' do
150
+ error = IOError.new('fake aggregate failure')
151
+ allow(client).to receive(:query).and_raise(error)
152
+ expect { query.public_send(operation, *arguments) }.to raise_error { |raised| expect(raised).to equal(error) }
153
+ expect(events.size).to eq(1)
154
+ expect(events.first.payload[:exception_object]).to equal(error)
155
+ end
156
+ end
157
+ end
158
+
159
+ it 'does not emit for invalid sum arguments' do
160
+ expect { query.sum(nil) }.to raise_error(ArgumentError)
161
+ expect { query.sum(:unknown) }.to raise_error(ActiveForce::UnknownFieldError)
162
+ expect(events).to be_empty
163
+ end
164
+ 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.26.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eloy Espinaco
@@ -198,6 +198,7 @@ files:
198
198
  - spec/active_force/composite_batch_query_spec.rb
199
199
  - spec/active_force/field_spec.rb
200
200
  - spec/active_force/mapping_spec.rb
201
+ - spec/active_force/query_notifications_spec.rb
201
202
  - spec/active_force/query_spec.rb
202
203
  - spec/active_force/sobject/includes_spec.rb
203
204
  - spec/active_force/sobject/select_spec.rb
@@ -233,7 +234,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
233
234
  - !ruby/object:Gem::Version
234
235
  version: '0'
235
236
  requirements: []
236
- rubygems_version: 3.6.9
237
+ rubygems_version: 4.0.16
237
238
  specification_version: 4
238
239
  summary: Help you implement models persisting on Sales Force within Rails using RESTForce
239
240
  test_files:
@@ -248,6 +249,7 @@ test_files:
248
249
  - spec/active_force/composite_batch_query_spec.rb
249
250
  - spec/active_force/field_spec.rb
250
251
  - spec/active_force/mapping_spec.rb
252
+ - spec/active_force/query_notifications_spec.rb
251
253
  - spec/active_force/query_spec.rb
252
254
  - spec/active_force/sobject/includes_spec.rb
253
255
  - spec/active_force/sobject/select_spec.rb