active_force 0.7.1 → 0.15.0

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