From dba872745931351d5ab8d8cba9ad8ede8cbfec67 Mon Sep 17 00:00:00 2001 From: Billy Frey <87046098+bfrey08@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:08:21 -0600 Subject: [PATCH] Nested select statements v.2 (#102) * 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 --- CHANGELOG.md | 3 ++ README.md | 11 +++++ lib/active_force/active_query.rb | 13 ++++-- .../eager_load_builder_for_nested_includes.rb | 24 +++++++---- .../eager_load_projection_builder.rb | 24 ++++++----- lib/active_force/query.rb | 2 +- lib/active_force/select_builder.rb | 41 +++++++++++++++++++ spec/active_force/sobject/includes_spec.rb | 40 ++++++++++++++++++ 8 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 lib/active_force/select_builder.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2633d..134bd58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index dad6d6a..8164a32 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/active_force/active_query.rb b/lib/active_force/active_query.rb index 401da3a..8ca689c 100644 --- a/lib/active_force/active_query.rb +++ b/lib/active_force/active_query.rb @@ -1,5 +1,6 @@ require 'active_support/all' require 'active_force/query' +require 'active_force/select_builder' require 'forwardable' module ActiveForce @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/lib/active_force/association/eager_load_builder_for_nested_includes.rb b/lib/active_force/association/eager_load_builder_for_nested_includes.rb index be158f4..b0e1a5d 100644 --- a/lib/active_force/association/eager_load_builder_for_nested_includes.rb +++ b/lib/active_force/association/eager_load_builder_for_nested_includes.rb @@ -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 @@ -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] @@ -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 @@ -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 diff --git a/lib/active_force/association/eager_load_projection_builder.rb b/lib/active_force/association/eager_load_projection_builder.rb index 60a82ae..ce9c00b 100644 --- a/lib/active_force/association/eager_load_projection_builder.rb +++ b/lib/active_force/association/eager_load_projection_builder.rb @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/lib/active_force/query.rb b/lib/active_force/query.rb index fcaf35c..423e45c 100644 --- a/lib/active_force/query.rb +++ b/lib/active_force/query.rb @@ -1,6 +1,6 @@ module ActiveForce class Query - attr_reader :table + attr_reader :table, :query_fields def initialize table @table = table diff --git a/lib/active_force/select_builder.rb b/lib/active_force/select_builder.rb new file mode 100644 index 0000000..daeaf8f --- /dev/null +++ b/lib/active_force/select_builder.rb @@ -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 diff --git a/spec/active_force/sobject/includes_spec.rb b/spec/active_force/sobject/includes_spec.rb index 65cd54c..b14ad3b 100644 --- a/spec/active_force/sobject/includes_spec.rb +++ b/spec/active_force/sobject/includes_spec.rb @@ -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 @@ -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 @@ -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