Skip to content

Commit

Permalink
Nested select statements v.2 (#102)
Browse files Browse the repository at this point in the history
* initial commit

* fix spacing

* add a couple tests

* add two more tests

* fix nested belongs_to select statements

* changelog and readme

* remove unused delegator

* add hole in spec to cover regression

* remove forwardable as per code review
  • Loading branch information
bfrey08 authored Oct 1, 2024
1 parent 3b9dbb0 commit dba8727
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 24 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Not released

## 0.24.0
- Add support for nested select statements that are used in conjuction with nested includes (https://github.com/Beyond-Finance/active_force/pull/102)

## 0.23.0
- Partially addresses #90. `#select` accepts a block and returns an array of filtered SObjects. (https://github.com/Beyond-Finance/active_force/pull/99)

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@ Comment.includes(post: :owner)
Comment.includes({post: {owner: :account}})
```

You can also use #select with a multi level #includes.

Examples:

```ruby
Comment.select(:body, post: [:title, :is_active]).includes(post: :owner)
Comment.select(:body, account: :owner_id).includes({post: {owner: :account}})
```

The Sobject name in the #select must match the Sobject in the #includes for the fields to be filtered.

### Aggregates

Summing the values of a column:
Expand Down
13 changes: 9 additions & 4 deletions lib/active_force/active_query.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'active_support/all'
require 'active_force/query'
require 'active_force/select_builder'
require 'forwardable'

module ActiveForce
Expand All @@ -25,7 +26,7 @@ def initialize(message = nil, table_name = nil, conditions = nil)
class ActiveQuery < Query
extend Forwardable

attr_reader :sobject, :association_mapping, :belongs_to_association_mapping
attr_reader :sobject, :association_mapping, :belongs_to_association_mapping, :nested_query_fields

def_delegators :sobject, :sfdc_client, :build, :table_name, :mappings
def_delegators :to_a, :blank?, :present?, :any?, :each, :map, :inspect, :pluck, :each_with_object
Expand All @@ -36,6 +37,7 @@ def initialize(sobject, custom_table_name = nil)
@belongs_to_association_mapping = {}
super custom_table_name || table_name
fields sobject.fields
@nested_query_fields = []
end

def to_a
Expand Down Expand Up @@ -86,8 +88,11 @@ def select *selected_fields, &block
end
result
else
selected_fields.map! { |field| mappings[field] }
super *selected_fields
fields_collection = ActiveForce::SelectBuilder.new(selected_fields, self).parse
nested_query_fields.concat(fields_collection[:nested_query_fields]) if fields_collection[:nested_query_fields]
return self if fields_collection[:non_nested_query_fields].blank?

super *fields_collection[:non_nested_query_fields]
end
end

Expand All @@ -114,7 +119,7 @@ def find_by!(conditions)
end

def includes(*relations)
includes_query = Association::EagerLoadBuilderForNestedIncludes.build(relations, sobject)
includes_query = Association::EagerLoadBuilderForNestedIncludes.build(relations, sobject, nil, nested_query_fields)
fields includes_query[:fields]
association_mapping.merge!(includes_query[:association_mapping])
self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ class InvalidAssociationError < StandardError; end
class EagerLoadBuilderForNestedIncludes

class << self
def build(relations, current_sobject, parent_association_field = nil)
new(relations, current_sobject, parent_association_field).projections
def build(relations, current_sobject, parent_association_field = nil, query_fields = nil)
new(relations, current_sobject, parent_association_field, query_fields).projections
end
end

attr_reader :relations, :current_sobject, :association_mapping, :parent_association_field, :fields
attr_reader :relations, :current_sobject, :association_mapping, :parent_association_field, :fields, :query_fields

def initialize(relations, current_sobject, parent_association_field = nil)
def initialize(relations, current_sobject, parent_association_field = nil, query_fields = nil)
@relations = [relations].flatten
@current_sobject = current_sobject
@association_mapping = {}
@parent_association_field = parent_association_field
@query_fields = query_fields
@fields = []
end

Expand All @@ -37,10 +38,17 @@ def projections
end

def build_includes(association)
fields.concat(EagerLoadProjectionBuilder.build(association, parent_association_field))
fields.concat(EagerLoadProjectionBuilder.build(association, parent_association_field, query_fields_for(association)))
association_mapping[association.sfdc_association_field.downcase] = association.relation_name
end

def query_fields_for(association)
return nil if query_fields.blank?
query_fields_with_association = query_fields.find { |nested_field| nested_field[association.relation_name].present? }
return nil if query_fields_with_association.blank?
query_fields_with_association[association.relation_name].map { |field| association.relation_model.mappings[field] }
end

def build_hash_includes(relation, model = current_sobject, parent_association_field = nil)
relation.each do |key, value|
association = model.associations[key]
Expand All @@ -63,10 +71,10 @@ def build_hash_includes(relation, model = current_sobject, parent_association_fi

def build_relation(association, nested_includes)
builder_class = ActiveForce::Association::EagerLoadProjectionBuilder.projection_builder_class(association)
projection_builder = builder_class.new(association)
projection_builder = builder_class.new(association, nil, query_fields_for(association))
sub_query = projection_builder.query_with_association_fields
association_mapping[association.sfdc_association_field.downcase] = association.relation_name
nested_includes_query = self.class.build(nested_includes, association.relation_model)
nested_includes_query = self.class.build(nested_includes, association.relation_model, nil, query_fields)
sub_query.fields nested_includes_query[:fields]
{ fields: ["(#{sub_query})"], association_mapping: nested_includes_query[:association_mapping] }
end
Expand All @@ -78,7 +86,7 @@ def build_relation_for_belongs_to(association, nested_includes)
else
current_parent_association_field = association.sfdc_association_field
end
self.class.build(nested_includes, association.relation_model, current_parent_association_field)
self.class.build(nested_includes, association.relation_model, current_parent_association_field, query_fields)
end
end
end
Expand Down
24 changes: 13 additions & 11 deletions lib/active_force/association/eager_load_projection_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ module Association
class InvalidEagerLoadAssociation < StandardError; end
class EagerLoadProjectionBuilder
class << self
def build(association, parent_association_field = nil)
new(association, parent_association_field).projections
def build(association, parent_association_field = nil, query_fields = nil)
new(association, parent_association_field, query_fields).projections
end

def projection_builder_class(association)
Expand All @@ -15,26 +15,27 @@ def projection_builder_class(association)
end
end

attr_reader :association, :parent_association_field
attr_reader :association, :parent_association_field, :query_fields

def initialize(association, parent_association_field = nil)
def initialize(association, parent_association_field = nil, query_fields = nil)
@association = association
@parent_association_field = parent_association_field
@query_fields = query_fields
end

def projections
builder_class = self.class.projection_builder_class(association)
builder_class.new(association, parent_association_field).projections
builder_class.new(association, parent_association_field, query_fields).projections
end

end

class AbstractProjectionBuilder
attr_reader :association, :parent_association_field
attr_reader :association, :parent_association_field, :query_fields

def initialize(association, parent_association_field = nil)
def initialize(association, parent_association_field = nil, query_fields = nil)
@association = association
@parent_association_field = parent_association_field
@query_fields = query_fields
end

def projections
Expand All @@ -54,8 +55,8 @@ def apply_association_scope(query)
# to be pluralized
def query_with_association_fields
relationship_name = association.sfdc_association_field
query = ActiveQuery.new(association.relation_model, relationship_name)
query.fields association.relation_model.fields
selected_fields = query_fields || association.relation_model.fields
query = ActiveQuery.new(association.relation_model, relationship_name).select(*selected_fields)
apply_association_scope(query)
end
end
Expand All @@ -79,7 +80,8 @@ def projections
else
association.sfdc_association_field
end
association.relation_model.fields.map do |field|
selected_fields = query_fields || association.relation_model.fields
selected_fields.map do |field|
"#{ association_field }.#{ field }"
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/active_force/query.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module ActiveForce
class Query
attr_reader :table
attr_reader :table, :query_fields

def initialize table
@table = table
Expand Down
41 changes: 41 additions & 0 deletions lib/active_force/select_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module ActiveForce
class SelectBuilder

attr_reader :selected_fields, :nested_query_fields, :non_nested_query_fields, :query

def initialize(selected_fields, query)
@query = query
@selected_fields = selected_fields
@non_nested_query_fields = []
@nested_query_fields = []
end

def parse
selected_fields.each do |field|
case field
when Symbol
non_nested_query_fields << query.mappings[field]
when Hash
populate_nested_query_fields(field)
when String
non_nested_query_fields << field
end
end
{non_nested_query_fields: non_nested_query_fields, nested_query_fields: nested_query_fields}
end

private

def populate_nested_query_fields(field)
field.each do |key, value|
case value
when Symbol
field[key] = [value]
when Hash
raise ArgumentError, 'Nested Hash is not supported in select statement, you may wish to use an Array'
end
end
nested_query_fields << field
end
end
end
40 changes: 40 additions & 0 deletions spec/active_force/sobject/includes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ module ActiveForce
expect(territory.quota.id).to eq "321"
end

context 'when nested select statement' do
it 'formulates the correct SOQL query' do
soql = Salesforce::Territory.select(:id, :quota_id, quota: :id).includes(:quota).where(id: '123').to_s
expect(soql).to eq "SELECT Id, QuotaId, QuotaId.Id FROM Territory WHERE (Id = '123')"
end

it 'errors when correct format is not followed' do
expect{Salesforce::Territory.select(:id, :quota_id, quota: {id: :quote}).includes(:quota).where(id: '123').to_s}.to raise_error ArgumentError
end

context 'when nested includes statement' do
it 'formulates the correct SOQL query' do
soql = Comment.select(:post_id, :body, post: [:title, :is_active], blog: :name).includes(post: :blog).to_s

expect(soql).to eq "SELECT PostId, Body__c, PostId.Title__c, PostId.IsActive, PostId.BlogId.Name FROM Comment__c"
end
end
end

context 'with namespaced SObjects' do
it 'queries the API for the associated record' do
soql = Salesforce::Territory.includes(:quota).where(id: '123').to_s
Expand Down Expand Up @@ -156,6 +175,20 @@ module ActiveForce
end

context 'has_many' do
context 'when nested select statement' do
it 'formulates the correct SOQL query' do
soql = Account.select(opportunities: :id).includes(:opportunities).where(id: '123').to_s
expect(soql).to eq "SELECT Id, OwnerId, (SELECT Id FROM Opportunities) FROM Account WHERE (Id = '123')"
end
end

context 'when normal select with nested includes' do
it 'formulates the correct SOQL query' do
soql = Blog.select(:id, :link).includes(posts: :comments).to_s
expect(soql).to eq "SELECT Id, Link__c, (SELECT Id, Title__c, BlogId, IsActive, (SELECT Id, PostId, PosterId__c, FancyPostId, Body__c FROM Comments__r) FROM Posts__r) FROM Blog__c"
end
end

context 'with standard objects' do
it 'formulates the correct SOQL query' do
soql = Account.includes(:opportunities).where(id: '123').to_s
Expand Down Expand Up @@ -294,6 +327,13 @@ module ActiveForce
end

context 'has_one' do
context 'when nested select statement is present' do
it 'formulates the correct SOQL query' do
soql = ClubMember.select(:name, :email, membership: :type).includes(:membership).to_s
expect(soql).to eq "SELECT Name, Email, (SELECT Type FROM Membership__r) FROM ClubMember__c"
end
end

context 'when assocation has a scope' do
it 'formulates the correct SOQL query with the scope applied' do
soql = Post.includes(:last_comment).where(id: '1234').to_s
Expand Down

0 comments on commit dba8727

Please sign in to comment.