active_force 0.7.1 → 0.15.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.
Files changed (44) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +107 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.mailmap +3 -0
  6. data/CHANGELOG.md +115 -42
  7. data/CODEOWNERS +2 -0
  8. data/Gemfile +0 -1
  9. data/README.md +100 -21
  10. data/active_force.gemspec +11 -4
  11. data/lib/active_force/active_query.rb +107 -6
  12. data/lib/active_force/association/association.rb +47 -3
  13. data/lib/active_force/association/belongs_to_association.rb +25 -11
  14. data/lib/active_force/association/eager_load_projection_builder.rb +9 -3
  15. data/lib/active_force/association/has_many_association.rb +19 -19
  16. data/lib/active_force/association/has_one_association.rb +30 -0
  17. data/lib/active_force/association/relation_model_builder.rb +1 -1
  18. data/lib/active_force/association.rb +6 -4
  19. data/lib/active_force/{attribute.rb → field.rb} +3 -3
  20. data/lib/active_force/mapping.rb +6 -32
  21. data/lib/active_force/query.rb +21 -2
  22. data/lib/active_force/sobject.rb +74 -28
  23. data/lib/active_force/version.rb +3 -1
  24. data/lib/active_force.rb +2 -0
  25. data/lib/active_model/type/salesforce/multipicklist.rb +29 -0
  26. data/lib/active_model/type/salesforce/percent.rb +22 -0
  27. data/lib/generators/active_force/model/model_generator.rb +32 -21
  28. data/lib/generators/active_force/model/templates/model.rb.erb +3 -1
  29. data/spec/active_force/active_query_spec.rb +200 -8
  30. data/spec/active_force/association/relation_model_builder_spec.rb +22 -0
  31. data/spec/active_force/association_spec.rb +252 -9
  32. data/spec/active_force/field_spec.rb +34 -0
  33. data/spec/active_force/query_spec.rb +26 -0
  34. data/spec/active_force/sobject/includes_spec.rb +10 -10
  35. data/spec/active_force/sobject_spec.rb +221 -14
  36. data/spec/fixtures/sobject/single_sobject_hash.yml +1 -1
  37. data/spec/spec_helper.rb +5 -2
  38. data/spec/support/bangwhiz.rb +7 -0
  39. data/spec/support/restforce_factories.rb +1 -1
  40. data/spec/support/sobjects.rb +17 -1
  41. data/spec/support/whizbang.rb +2 -2
  42. metadata +64 -26
  43. data/lib/active_attr/dirty.rb +0 -24
  44. data/spec/active_force/attribute_spec.rb +0 -27
@@ -1,24 +1,42 @@
1
+ require 'active_support/all'
1
2
  require 'active_force/query'
2
3
  require 'forwardable'
3
4
 
4
5
  module ActiveForce
5
6
  class PreparedStatementInvalid < ArgumentError; end
7
+
8
+ class RecordNotFound < StandardError
9
+ attr_reader :table_name, :conditions
10
+
11
+ def initialize(message = nil, table_name = nil, conditions = nil)
12
+ @table_name = table_name
13
+ @conditions = conditions
14
+
15
+ super(message)
16
+ end
17
+ end
18
+
6
19
  class ActiveQuery < Query
7
20
  extend Forwardable
8
21
 
9
- attr_reader :sobject
22
+ attr_reader :sobject, :association_mapping
10
23
 
11
24
  def_delegators :sobject, :sfdc_client, :build, :table_name, :mappings
12
- def_delegators :to_a, :each, :map, :inspect
25
+ def_delegators :to_a, :each, :map, :inspect, :pluck, :each_with_object
13
26
 
14
27
  def initialize sobject
15
28
  @sobject = sobject
29
+ @association_mapping = {}
16
30
  super table_name
17
31
  fields sobject.fields
18
32
  end
19
33
 
20
34
  def to_a
21
- @records ||= result.to_a.map { |mash| build mash }
35
+ @decorated_records ||= sobject.try(:decorate, records) || records
36
+ end
37
+
38
+ private def records
39
+ @records ||= result.to_a.map { |mash| build mash, association_mapping }
22
40
  end
23
41
 
24
42
  alias_method :all, :to_a
@@ -28,13 +46,34 @@ module ActiveForce
28
46
  sfdc_client.query(to_s).first.expr0
29
47
  end
30
48
 
49
+ def sum field
50
+ super(mappings[field])
51
+ sfdc_client.query(to_s).first.expr0
52
+ end
53
+
54
+ def pluck(*fields)
55
+ fields = mappings.keys if fields.blank?
56
+
57
+ sfdc_client.query(select(*fields).to_s).map do |record|
58
+ values = fields.map { |field| cast_value(field, record) }
59
+ values.length == 1 ? values.first : values
60
+ end
61
+ end
62
+
31
63
  def limit limit
32
64
  super
33
65
  limit == 1 ? to_a.first : self
34
66
  end
35
67
 
68
+ def not args=nil, *rest
69
+ return self if args.nil?
70
+ super build_condition args, rest
71
+ self
72
+ end
73
+
36
74
  def where args=nil, *rest
37
75
  return self if args.nil?
76
+ return clone_self_and_clear_cache.where(args, *rest) if @decorated_records.present?
38
77
  super build_condition args, rest
39
78
  self
40
79
  end
@@ -44,18 +83,48 @@ module ActiveForce
44
83
  super *fields
45
84
  end
46
85
 
86
+ def find!(id)
87
+ result = find(id)
88
+ raise RecordNotFound.new("Couldn't find #{table_name} with id #{id}", table_name, id: id) if result.nil?
89
+
90
+ result
91
+ end
92
+
47
93
  def find_by conditions
48
94
  where(conditions).limit 1
49
95
  end
50
96
 
97
+ def find_by!(conditions)
98
+ result = find_by(conditions)
99
+ raise RecordNotFound.new("Couldn't find #{table_name} with #{conditions}", table_name, conditions) if result.nil?
100
+
101
+ result
102
+ end
103
+
51
104
  def includes(*relations)
52
105
  relations.each do |relation|
53
106
  association = sobject.associations[relation]
54
107
  fields Association::EagerLoadProjectionBuilder.build association
108
+ # downcase the key and downcase when we do the comparison so we don't do any more crazy string manipulation
109
+ association_mapping[association.sfdc_association_field.downcase] = association.relation_name
55
110
  end
56
111
  self
57
112
  end
58
113
 
114
+ def none
115
+ @records = []
116
+ where(id: '1'*18).where(id: '0'*18)
117
+ end
118
+
119
+ def loaded?
120
+ !@records.nil?
121
+ end
122
+
123
+ def order *args
124
+ return self if args.nil?
125
+ super build_order_by args
126
+ end
127
+
59
128
  private
60
129
 
61
130
  def build_condition(args, other=[])
@@ -130,21 +199,53 @@ module ActiveForce
130
199
  def enclose_value value
131
200
  case value
132
201
  when String
133
- "'#{quote_string(value)}'"
202
+ quote_string(value)
134
203
  when NilClass
135
204
  'NULL'
205
+ when Time
206
+ value.iso8601
136
207
  else
137
208
  value.to_s
138
209
  end
139
210
  end
140
211
 
141
212
  def quote_string(s)
142
- # From activerecord/lib/active_record/connection_adapters/abstract/quoting.rb, version 4.1.5, line 82
143
- s.gsub(/\\/, '\&\&').gsub(/'/, "''")
213
+ "'#{s.gsub(/(['\\])/, '\\\\\\1')}'"
144
214
  end
145
215
 
146
216
  def result
147
217
  sfdc_client.query(self.to_s)
148
218
  end
219
+
220
+ def cast_value(field, object)
221
+ attribute_type = sobject.attribute_types[field.to_s]
222
+ value = object[mappings[field]]
223
+ attribute_type&.cast(value) || value
224
+ end
225
+
226
+ def clone_self_and_clear_cache
227
+ new_query = self.clone
228
+ new_query.instance_variable_set(:@decorated_records, nil)
229
+ new_query.instance_variable_set(:@records, nil)
230
+ new_query
231
+ end
232
+
233
+ def build_order_by(args)
234
+ args.map do |arg|
235
+ case arg
236
+ when Symbol
237
+ mappings[arg].to_s
238
+ when Hash
239
+ arg.map { |key, value| "#{mappings[key]} #{order_type(value)}" }
240
+ else
241
+ arg
242
+ end
243
+ end.join(', ')
244
+ end
245
+
246
+ def order_type(type)
247
+ type == :desc ? 'DESC' : 'ASC'
248
+ end
249
+
149
250
  end
150
251
  end
@@ -11,10 +11,11 @@ module ActiveForce
11
11
  @relation_name = relation_name
12
12
  @options = options
13
13
  define_relation_method
14
+ define_assignment_method
14
15
  end
15
16
 
16
17
  def relation_model
17
- options[:model] || relation_name.to_s.singularize.camelcase.constantize
18
+ (options[:model] || relation_name.to_s.singularize.camelcase).to_s.constantize
18
19
  end
19
20
 
20
21
  def foreign_key
@@ -22,7 +23,7 @@ module ActiveForce
22
23
  end
23
24
 
24
25
  def relationship_name
25
- options[:relationship_name] || relation_model.table_name
26
+ options[:relationship_name] || relation_model.to_s.constantize.table_name
26
27
  end
27
28
 
28
29
  ###
@@ -38,13 +39,56 @@ module ActiveForce
38
39
  relationship_name.gsub /__c\z/, '__r'
39
40
  end
40
41
 
42
+ def load_target(owner)
43
+ if loadable?(owner)
44
+ target(owner)
45
+ else
46
+ target_when_unloadable
47
+ end
48
+ end
49
+
41
50
  private
42
51
 
52
+ attr_reader :parent
53
+
54
+ def loadable?(owner)
55
+ owner&.persisted?
56
+ end
57
+
58
+ def target(_owner)
59
+ raise NoMethodError, 'target must be implemented'
60
+ end
61
+
62
+ def target_when_unloadable
63
+ nil
64
+ end
65
+
66
+ def define_relation_method
67
+ association = self
68
+ method_name = relation_name
69
+ parent.send(:define_method, method_name) do
70
+ association_cache.fetch(method_name) { association_cache[method_name] = association.load_target(self) }
71
+ end
72
+ end
73
+
74
+ def define_assignment_method
75
+ raise NoMethodError, 'define_assignment_method must be implemented'
76
+ end
77
+
43
78
  def infer_foreign_key_from_model(model)
44
79
  name = model.custom_table? ? model.name : model.table_name
45
80
  name.foreign_key.to_sym
46
81
  end
47
- end
48
82
 
83
+ def apply_scope(query, owner)
84
+ return query unless (scope = options[:scoped_as])
85
+
86
+ if scope.arity.positive?
87
+ query.instance_exec(owner, &scope)
88
+ else
89
+ query.instance_exec(&scope)
90
+ end
91
+ end
92
+ end
49
93
  end
50
94
  end
@@ -1,24 +1,38 @@
1
1
  module ActiveForce
2
2
  module Association
3
3
  class BelongsToAssociation < Association
4
+ def relationship_name
5
+ options[:relationship_name] || default_relationship_name
6
+ end
7
+
4
8
  private
5
9
 
10
+ def loadable?(owner)
11
+ foreign_key_value(owner).present?
12
+ end
13
+
14
+ def target(owner)
15
+ relation_model.find(foreign_key_value(owner))
16
+ end
17
+
18
+ def default_relationship_name
19
+ parent.mappings[foreign_key].gsub(/__c\z/, '__r')
20
+ end
21
+
6
22
  def default_foreign_key
7
23
  infer_foreign_key_from_model relation_model
8
24
  end
9
25
 
10
- def define_relation_method
11
- association = self
12
- _method = @relation_name
13
- @parent.send :define_method, _method do
14
- association_cache.fetch(_method) do
15
- association_cache[_method] = association.relation_model.find(send association.foreign_key)
16
- end
17
- end
26
+ def foreign_key_value(owner)
27
+ owner&.public_send(foreign_key)
28
+ end
18
29
 
19
- @parent.send :define_method, "#{_method}=" do |other|
20
- send "#{ association.foreign_key }=", other.nil? ? nil : other.id
21
- association_cache[_method] = other
30
+ def define_assignment_method
31
+ association = self
32
+ method_name = relation_name
33
+ parent.send :define_method, "#{method_name}=" do |other|
34
+ send "#{association.foreign_key}=", other&.id
35
+ association_cache[method_name] = other
22
36
  end
23
37
  end
24
38
  end
@@ -40,15 +40,21 @@ module ActiveForce
40
40
  # relationship name. Per SFDC convention, the name needs
41
41
  # to be pluralized
42
42
  def projections
43
- match = association.sfdc_association_field.match /__r\z/
44
- # pluralize the table name, and append '__r' if it was there to begin with
45
- relationship_name = association.sfdc_association_field.sub(match.to_s, '').pluralize + match.to_s
43
+ relationship_name = association.sfdc_association_field
46
44
  query = Query.new relationship_name
47
45
  query.fields association.relation_model.fields
48
46
  ["(#{query.to_s})"]
49
47
  end
50
48
  end
51
49
 
50
+ class HasOneAssociationProjectionBuilder < AbstractProjectionBuilder
51
+ def projections
52
+ query = Query.new association.sfdc_association_field
53
+ query.fields association.relation_model.fields
54
+ ["(#{query.to_s})"]
55
+ end
56
+ end
57
+
52
58
  class BelongsToAssociationProjectionBuilder < AbstractProjectionBuilder
53
59
  def projections
54
60
  association.relation_model.fields.map do |field|
@@ -1,31 +1,31 @@
1
1
  module ActiveForce
2
2
  module Association
3
3
  class HasManyAssociation < Association
4
+ def sfdc_association_field
5
+ name = relationship_name.gsub(/__c\z/, '__r')
6
+ match = name.match(/__r\z/)
7
+ # pluralize the table name, and append '__r' if it was there to begin with
8
+ name.sub(match.to_s, '').pluralize + match.to_s
9
+ end
10
+
4
11
  private
5
12
 
6
13
  def default_foreign_key
7
- infer_foreign_key_from_model @parent
14
+ infer_foreign_key_from_model parent
8
15
  end
9
16
 
10
- def define_relation_method
11
- association = self
12
- _method = @relation_name
13
- @parent.send :define_method, _method do
14
- association_cache.fetch _method do
15
- query = association.relation_model.query
16
- if scope = association.options[:scoped_as]
17
- if scope.arity > 0
18
- query.instance_exec self, &scope
19
- else
20
- query.instance_exec &scope
21
- end
22
- end
23
- association_cache[_method] = query.where association.foreign_key => self.id
24
- end
25
- end
17
+ def target(owner)
18
+ apply_scope(relation_model.query, owner).where(foreign_key => owner.id)
19
+ end
20
+
21
+ def target_when_unloadable
22
+ relation_model.none
23
+ end
26
24
 
27
- @parent.send :define_method, "#{_method}=" do |associated|
28
- association_cache[_method] = associated
25
+ def define_assignment_method
26
+ method_name = relation_name
27
+ parent.send :define_method, "#{method_name}=" do |associated|
28
+ association_cache[method_name] = associated
29
29
  end
30
30
  end
31
31
  end
@@ -0,0 +1,30 @@
1
+ module ActiveForce
2
+ module Association
3
+ class HasOneAssociation < Association
4
+ private
5
+
6
+ def target(owner)
7
+ apply_scope(relation_model.query, owner).find_by(foreign_key => owner.id)
8
+ end
9
+
10
+ def default_foreign_key
11
+ infer_foreign_key_from_model parent
12
+ end
13
+
14
+ def define_assignment_method
15
+ foreign_key = self.foreign_key
16
+ method_name = relation_name
17
+ parent.send :define_method, "#{method_name}=" do |new_target|
18
+ new_target = new_target.first if new_target.is_a?(Array)
19
+ if new_target.present?
20
+ new_target.public_send("#{foreign_key}=", id)
21
+ else
22
+ current_target = public_send(method_name)
23
+ current_target&.public_send("#{foreign_key}=", nil)
24
+ end
25
+ association_cache[method_name] = new_target
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -54,7 +54,7 @@ module ActiveForce
54
54
 
55
55
  class BuildFromNilClass < AbstractBuildFrom
56
56
  def call
57
- association.is_a?(BelongsToAssociation) ? nil : []
57
+ association.is_a?(HasManyAssociation) ? [] : nil
58
58
  end
59
59
  end
60
60
 
@@ -2,6 +2,7 @@ require 'active_force/association/association'
2
2
  require 'active_force/association/eager_load_projection_builder'
3
3
  require 'active_force/association/relation_model_builder'
4
4
  require 'active_force/association/has_many_association'
5
+ require 'active_force/association/has_one_association'
5
6
  require 'active_force/association/belongs_to_association'
6
7
 
7
8
  module ActiveForce
@@ -10,17 +11,18 @@ module ActiveForce
10
11
  @associations ||= {}
11
12
  end
12
13
 
13
- # i.e name = 'Quota__r'
14
14
  def find_association name
15
- associations.values.detect do |association|
16
- association.represents_sfdc_table? name
17
- end
15
+ associations[name.to_sym]
18
16
  end
19
17
 
20
18
  def has_many relation_name, options = {}
21
19
  associations[relation_name] = HasManyAssociation.new(self, relation_name, options)
22
20
  end
23
21
 
22
+ def has_one relation_name, options = {}
23
+ associations[relation_name] = HasOneAssociation.new(self, relation_name, options)
24
+ end
25
+
24
26
  def belongs_to relation_name, options = {}
25
27
  associations[relation_name] = BelongsToAssociation.new(self, relation_name, options)
26
28
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveForce
2
- class Attribute
2
+ class Field
3
3
 
4
4
  attr_accessor :local_name, :sfdc_name, :as
5
5
 
@@ -11,8 +11,8 @@ module ActiveForce
11
11
 
12
12
  def value_for_hash value
13
13
  case as
14
- when :multi_picklist
15
- value.reject(&:empty?).join(';')
14
+ when :datetime
15
+ value&.to_fs(:iso8601)
16
16
  else
17
17
  value
18
18
  end
@@ -1,4 +1,4 @@
1
- require 'active_force/attribute'
1
+ require 'active_force/field'
2
2
  require 'active_force/table'
3
3
  require 'forwardable'
4
4
 
@@ -6,12 +6,6 @@ module ActiveForce
6
6
  class Mapping
7
7
  extend Forwardable
8
8
 
9
- STRINGLIKE_TYPES = [
10
- nil, :string, :base64, :byte, :ID, :reference, :currency, :textarea,
11
- :phone, :url, :email, :combobox, :picklist, :multipicklist, :anyType,
12
- :location
13
- ]
14
-
15
9
  def_delegators :table, :custom_table?, :table_name
16
10
 
17
11
  def initialize model
@@ -19,7 +13,7 @@ module ActiveForce
19
13
  end
20
14
 
21
15
  def mappings
22
- Hash[fields.map { |field, attr| [field, attr.sfdc_name] }]
16
+ @mappings ||= Hash[fields.map { |field, attr| [field, attr.sfdc_name] }]
23
17
  end
24
18
 
25
19
  def sfdc_names
@@ -27,7 +21,7 @@ module ActiveForce
27
21
  end
28
22
 
29
23
  def field name, options
30
- fields.merge!({ name => ActiveForce::Attribute.new(name, options) })
24
+ fields.merge!({ name => ActiveForce::Field.new(name, options) })
31
25
  end
32
26
 
33
27
  def table
@@ -36,15 +30,15 @@ module ActiveForce
36
30
 
37
31
  def translate_to_sf attributes
38
32
  attrs = attributes.map do |attribute, value|
39
- attr = fields[attribute.to_sym]
40
- [attr.sfdc_name, attr.value_for_hash(value)]
33
+ field = fields[attribute.to_sym]
34
+ [field.sfdc_name, field.value_for_hash(value)]
41
35
  end
42
36
  Hash[attrs]
43
37
  end
44
38
 
45
39
  def translate_value value, field_name
46
40
  return value unless !!field_name
47
- typecast_value value.to_s, fields[field_name].as
41
+ value
48
42
  end
49
43
 
50
44
 
@@ -54,25 +48,5 @@ module ActiveForce
54
48
  @fields ||= {}
55
49
  end
56
50
 
57
- # Handles Salesforce FieldTypes as described here:
58
- # http://www.salesforce.com/us/developer/docs/api/Content/sforce_api_calls_describesobjects_describesobjectresult.htm#i1427700
59
- def typecast_value value, type
60
- case type
61
- when *STRINGLIKE_TYPES
62
- value
63
- when :boolean
64
- !['false','0','f'].include? value.downcase
65
- when :int
66
- value.to_i
67
- when :double, :percent
68
- value.to_f
69
- when :date
70
- Date.parse value
71
- when :datetime
72
- DateTime.parse value
73
- else
74
- value
75
- end
76
- end
77
51
  end
78
52
  end
@@ -35,7 +35,17 @@ module ActiveForce
35
35
  self
36
36
  end
37
37
 
38
- def where condition
38
+ def not condition
39
+ @conditions << "NOT ((#{ condition.join(') AND (') }))"
40
+ self
41
+ end
42
+
43
+ def or query
44
+ @conditions = ["(#{ and_conditions }) OR (#{ query.and_conditions })"]
45
+ self
46
+ end
47
+
48
+ def where condition = nil
39
49
  @conditions << condition if condition
40
50
  self
41
51
  end
@@ -86,13 +96,22 @@ module ActiveForce
86
96
  self
87
97
  end
88
98
 
99
+ def sum field
100
+ @query_fields = ["sum(#{field})"]
101
+ self
102
+ end
103
+
89
104
  protected
105
+ def and_conditions
106
+ "(#{@conditions.join(') AND (')})" unless @conditions.empty?
107
+ end
108
+
90
109
  def build_select
91
110
  @query_fields.compact.uniq.join(', ')
92
111
  end
93
112
 
94
113
  def build_where
95
- "WHERE (#{ @conditions.join(') AND (') })" unless @conditions.empty?
114
+ "WHERE #{and_conditions}" unless @conditions.empty?
96
115
  end
97
116
 
98
117
  def build_limit