restforce-db 0.5.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -21
  3. data/lib/restforce/db.rb +6 -0
  4. data/lib/restforce/db/associations/active_record.rb +1 -1
  5. data/lib/restforce/db/attribute_map.rb +5 -1
  6. data/lib/restforce/db/dsl.rb +80 -0
  7. data/lib/restforce/db/initializer.rb +3 -2
  8. data/lib/restforce/db/mapping.rb +20 -77
  9. data/lib/restforce/db/model.rb +7 -4
  10. data/lib/restforce/db/registry.rb +62 -0
  11. data/lib/restforce/db/strategies/always.rb +38 -0
  12. data/lib/restforce/db/strategies/passive.rb +37 -0
  13. data/lib/restforce/db/strategy.rb +25 -0
  14. data/lib/restforce/db/version.rb +1 -1
  15. data/test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_Salesforce_record/{for_a_non-root_mapping → for_a_Passive_strategy}/does_not_create_a_database_record.yml +20 -20
  16. data/test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_Salesforce_record/for_an_Always_strategy/creates_a_matching_database_record.yml +159 -0
  17. data/test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_database_record/{for_a_root_mapping → for_an_Always_strategy}/populates_Salesforce_with_the_new_record.yml +44 -43
  18. data/test/cassettes/{Restforce_DB_Initializer/_run/given_an_existing_Salesforce_record/for_a_root_mapping/creates_a_matching_database_record.yml → Restforce_DB_Strategies_Always/_build_/given_a_Salesforce_record/wants_to_build_a_new_matching_record.yml} +30 -30
  19. data/test/cassettes/Restforce_DB_Strategies_Always/_build_/given_a_Salesforce_record/with_a_corresponding_database_record/does_not_want_to_build_a_new_record.yml +158 -0
  20. data/test/lib/restforce/db/associations/active_record_test.rb +6 -6
  21. data/test/lib/restforce/db/attribute_map_test.rb +5 -1
  22. data/test/lib/restforce/db/dsl_test.rb +80 -0
  23. data/test/lib/restforce/db/initializer_test.rb +8 -8
  24. data/test/lib/restforce/db/mapping_test.rb +7 -17
  25. data/test/lib/restforce/db/model_test.rb +8 -9
  26. data/test/lib/restforce/db/record_types/active_record_test.rb +10 -6
  27. data/test/lib/restforce/db/registry_test.rb +43 -0
  28. data/test/lib/restforce/db/strategies/always_test.rb +38 -0
  29. data/test/lib/restforce/db/strategies/passive_test.rb +17 -0
  30. data/test/lib/restforce/db/strategy_test.rb +19 -0
  31. data/test/support/utilities.rb +7 -9
  32. metadata +17 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 530452ffeea884be628f06f4227de2fe1404ebc4
4
- data.tar.gz: 2b94172a1c712955af08ac61f3131a1de4d3ea83
3
+ metadata.gz: b090d0384c8a485ace8705c39d9247d02a7b6c98
4
+ data.tar.gz: 835e23b61f087b6ef9d66baf0a927f19d285e6d6
5
5
  SHA512:
6
- metadata.gz: 6cb953ba90c351994aeccef4fd2ffefefe5364955fa6c566098166f5053d74f121a4d2cc69629bba386c96b932d7a7c8ad657dcc609c359cbb50c75b4cd3aa08
7
- data.tar.gz: d1e69dfef74e097947fcbe5abfaa31aa2d6b73e74e373ae2ad9605db9c0445ed26717c1b8531cec1e624b27adc1bcc3a13dea3ab25da40448be1199495a45548
6
+ metadata.gz: 05a85db585c7e7fe76c637dec68332123b1bf195c3ddde90747b33a3d202ce83a6e4ec0e9317bc81a6a2f80655cd62e85c7fb6882d442d0d89373187ec6c7a1a
7
+ data.tar.gz: d6a81ca6fece5297c1ce7b8e115f0fd04f24fef6110537bbed27a9b873f97694cd817475d6ec076f523696063694f891f9f6742412909a67c40e8abb79cd73e5
data/README.md CHANGED
@@ -41,16 +41,14 @@ class Restaurant < ActiveRecord::Base
41
41
  include Restforce::DB::Model
42
42
  has_one :specialty, class_name: "Dish"
43
43
 
44
- sync_with(
45
- "Restaurants__c",
46
- fields: {
44
+ sync_with("Restaurant__c", :always) do
45
+ where "StarRating__c > 4"
46
+ belongs_to :specialty, through: %w(Specialty__c KeyIngredient__c)
47
+ maps(
47
48
  name: "Name",
48
49
  style: "Style__c",
49
- },
50
- associations: {
51
- specialty: "Specialty__c",
52
- },
53
- )
50
+ )
51
+ end
54
52
 
55
53
  end
56
54
 
@@ -59,24 +57,82 @@ class Dish < ActiveRecord::Base
59
57
  include Restforce::DB::Model
60
58
  belongs_to :restaurant
61
59
 
62
- sync_with(
63
- "Dish__c",
64
- through: "Specialty__c",
65
- fields: {
66
- name: "Name",
67
- ingredient: "Ingredient__c",
68
- },
69
- )
60
+ sync_with("Dish__c", :passive) do
61
+ has_one :restaurant, through: "Specialty__c"
62
+ maps(
63
+ name: "Name",
64
+ origin: "Origin__c",
65
+ )
66
+ end
67
+
68
+ sync_with("Ingredient__c", :passive) do
69
+ has_one :restaurant, through: "KeyIngredient__c"
70
+ maps(
71
+ key_ingredient: "Name",
72
+ )
73
+ end
74
+
70
75
  end
71
76
  ```
72
77
 
73
78
  This will automatically register the models with entries in the `Restforce::DB::Mapping` collection. This collection defines the manner in which the database and Salesforce systems will be synchronized.
74
79
 
75
- There are a few options to be aware of when describing a mapping:
80
+ Demonstrated above, `Restforce::DB` has its own DSL for defining mappings, heavily inspired by the ActiveRecord model DSL. The various options are outlined here.
81
+
82
+ #### Synchronization Strategies
83
+
84
+ The second argument to `sync_with` is a Symbol, reflecting the desired synchronization strategy for the mapping. Valid options are as follows:
85
+
86
+ ##### `:always`
87
+
88
+ An `always` synchronization strategy will create any new records it encounters while polling for changes, and once the object has been persisted in both systems, will update that object any time changes are made to the matching object in the other system.
89
+
90
+ Associations defined on an `always` mapping will trigger the creation of those associated records on initial record creation.
91
+
92
+ ##### `:passive`
93
+
94
+ A `passive` synchronization strategy will update all modified records that already exist in both systems, but will not directly create any new records. Objects defined with a `passive` mapping can only be created as a by-product of another mapping's association definitions (via an `always` strategy).
95
+
96
+ ##### `:associated`
97
+
98
+ _Coming Soon_
99
+
100
+ #### Lookup Conditions
101
+
102
+ `where` accepts one or more query strings which will be used to filter _all_ queries performed for the specific mapping. In the example above, Restaurant objects will only be detected in Salesforce if they exceed a certain value for the `StarRating__c` field.
103
+
104
+ Individual conditions supplied to `where` will be appended together with `AND` clauses, and must be composed of valid [`SOQL`](http://www.salesforce.com/us/developer/docs/soql_sosl/).
105
+
106
+ #### Field Mappings
107
+
108
+ `maps` defines a set of direct field-to-field mappings. It takes a Hash as an argument; the keys should line up with your ActiveRecord attribute names, while the values should line up with the matching field names in Salesforce.
109
+
110
+ #### Associations
111
+
112
+ Associations in `Restforce::DB` can be a little tricky, as they depend on your ActiveRecord association mappings, but are independent of those mappings, and can even (as seen above) seem to conflict with them.
113
+
114
+ If your Salesforce objects have parity with your ActiveRecord models, your association mappings will likely have parity, as well. But, as demonstrated above, you should define your association mappings based on your Salesforce schema.
115
+
116
+ ##### `belongs_to`
117
+
118
+ This defines an association type in which the Lookup (i.e., foreign key) _is on the mapped Salesforce model_. In the example above, the `Restaurant__c` object type in Salesforce has two Lookup fields:
119
+
120
+ - `Specialty__c`, which corresponds to the `Dish__c` object type, and
121
+ - `KeyIngredient__c`, which corresponds to the `Ingredient__c` object type
122
+
123
+ Thus, the `Restaurant__c` mapping declares a `belongs_to` relationship to `:specialty`, with a `:through` argument referencing both of the Lookups used by the mappings on the associated `Dish` class.
124
+
125
+ As shown above, the `:through` option may contain _an array of Lookup field names_, which may be useful if more than one mapping on the associated ActiveRecord model refers to a Lookup on the same Salesforce record.
126
+
127
+ ##### `has_one`
128
+
129
+ This defines an inverse relationship for a `belongs_to` relationship. In the example above, `Dish` defines _two_ `has_one` relationships with `:restaurant`, one for each mapping. The `:through` arguments for each call to `has_one` correspond to the relevant Lookup field on the parent object.
130
+
131
+ In the above example, given the relationships defined between our records, we can ascertain that `Restaurant__c.Specialty__c` is a `Lookup(Dish__c)` field in Salesforce, while ` Restaurant__c.KeyIngredient__c` is a `Lookup(Ingredient__c)`.
132
+
133
+ ##### `has_many`
76
134
 
77
- - `fields`: These are direct field-to-field mappings. The keys should line up with your ActiveRecord attribute names, while the values should line up with the matching field names in Salesforce.
78
- - `associations`: These are mappings of ActiveRecord associations to Salesforce lookups. Associations defined here will be built as part of the creation process for a newly-synced record.
79
- - `through`: This should be set for models which are created through an association. It references the lookup field on its parent's Salesforce object type.
135
+ _Coming Soon_
80
136
 
81
137
  ### Run the daemon
82
138
 
@@ -100,7 +156,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
100
156
 
101
157
  ## Caveats
102
158
 
103
- - **Update Prioritization.** When synchronization occurs, newly-updated records in Salesforce are prioritized over newly-updated database records. This means that any changes to records in the database may be overwritten if changes were made to the Salesforce at the same time.
159
+ - **Update Prioritization.** When synchronization occurs, the most recently updated record, Salesforce or database, gets to make the final call about the values of _all_ of the fields it observes. This means that race conditions can and probably will happen if both systems are updated within the same polling interval.
104
160
  - **API Usage.** This gem performs most of its functionality via the Salesforce API (by way of the [`restforce`](https://github.com/ejholmes/restforce) gem). If you're at risk of hitting your Salesforce API limits, this may not be the right approach for you.
105
161
 
106
162
  ## Contributing
data/lib/restforce/db.rb CHANGED
@@ -5,6 +5,9 @@ require "restforce/extensions"
5
5
 
6
6
  require "restforce/db/version"
7
7
  require "restforce/db/configuration"
8
+ require "restforce/db/registry"
9
+ require "restforce/db/strategy"
10
+ require "restforce/db/dsl"
8
11
 
9
12
  require "restforce/db/associations/active_record"
10
13
 
@@ -16,6 +19,9 @@ require "restforce/db/record_types/base"
16
19
  require "restforce/db/record_types/active_record"
17
20
  require "restforce/db/record_types/salesforce"
18
21
 
22
+ require "restforce/db/strategies/always"
23
+ require "restforce/db/strategies/passive"
24
+
19
25
  require "restforce/db/runner"
20
26
 
21
27
  require "restforce/db/accumulator"
@@ -28,7 +28,7 @@ module Restforce
28
28
  #
29
29
  # Returns the constructed associated record.
30
30
  def build(from_record)
31
- Mapping[associated.class].each do |mapping|
31
+ Registry[associated.class].each do |mapping|
32
32
  lookup_id = from_record[mapping.through]
33
33
  apply(mapping, lookup_id)
34
34
  end
@@ -59,7 +59,11 @@ module Restforce
59
59
  #
60
60
  # Examples
61
61
  #
62
- # mapping = Mapping.new(MyClass, "Object__c", some_key: "SomeField__c")
62
+ # mapping = AttributeMap.new(
63
+ # MyClass,
64
+ # "Object__c",
65
+ # some_key: "SomeField__c",
66
+ # )
63
67
  #
64
68
  # mapping.convert("Object__c", some_key: "some value")
65
69
  # # => { "Some_Field__c" => "some value" }
@@ -0,0 +1,80 @@
1
+ module Restforce
2
+
3
+ module DB
4
+
5
+ # Restforce::DB::DSL defines a syntax through which a Mapping may be
6
+ # configured between a database model and an object type in Salesforce.
7
+ class DSL
8
+
9
+ attr_reader :mapping
10
+
11
+ # Public: Initialize a Restforce::DB::DSL.
12
+ #
13
+ # database_model - An ActiveRecord::Base subclass.
14
+ # salesforce_model - A String Salesforce object name.
15
+ # strategy_name - A Symbol initialization strategy name.
16
+ # options - A Hash of options to pass to the Strategy object.
17
+ #
18
+ # Returns nothing.
19
+ def initialize(database_model, salesforce_model, strategy_name, options = {})
20
+ strategy = Strategy.for(strategy_name, options)
21
+ @mapping = Mapping.new(database_model, salesforce_model, strategy)
22
+ Registry << @mapping
23
+ end
24
+
25
+ # Public: Define a set of conditions which should be used to filter the
26
+ # Salesforce record lookups for this mapping.
27
+ #
28
+ # conditions - An Array of String query conditions.
29
+ #
30
+ # Returns nothing.
31
+ def where(*conditions)
32
+ @mapping.conditions = conditions
33
+ end
34
+
35
+ # Public: Define a relationship in which the current mapping contains the
36
+ # lookup ID for another mapping.
37
+ #
38
+ # TODO: This should eventually be implemented as a full-fledged
39
+ # association on the mapping. Something like:
40
+ # @mapping.associate(association, Associations::BelongsTo, options)
41
+ #
42
+ # association - The name of the ActiveRecord association.
43
+ # options - A Hash of options to pass to the association.
44
+ #
45
+ # Returns nothing.
46
+ def belongs_to(association, options)
47
+ @mapping.associations[association] = options[:through]
48
+ end
49
+
50
+ # Public: Define a relationship in which the current mapping is referenced
51
+ # by one object through a lookup ID on another mapping.
52
+ #
53
+ # TODO: This should eventually be implemented as a full-fledged
54
+ # association on the mapping. Something like:
55
+ # @mapping.associate(association, Associations::HasOne, options)
56
+ #
57
+ # association - The name of the ActiveRecord association.
58
+ # options - A Hash of options to pass to the association.
59
+ #
60
+ # Returns nothing.
61
+ def has_one(_association, options) # rubocop:disable PredicateName
62
+ @mapping.through = options[:through]
63
+ end
64
+
65
+ # Public: Define a set of fields which should be synchronized between the
66
+ # database record and Salesforce.
67
+ #
68
+ # fields - A Hash, with keys corresponding to attributes of the database
69
+ # record, and values corresponding to field names in Salesforce.
70
+ #
71
+ # Returns nothing.
72
+ def maps(fields)
73
+ @mapping.fields = fields
74
+ end
75
+
76
+ end
77
+
78
+ end
79
+
80
+ end
@@ -14,6 +14,7 @@ module Restforce
14
14
  # runner - A Restforce::DB::Runner.
15
15
  def initialize(mapping, runner = Runner.new)
16
16
  @mapping = mapping
17
+ @strategy = mapping.strategy
17
18
  @runner = runner
18
19
  end
19
20
 
@@ -21,7 +22,7 @@ module Restforce
21
22
  #
22
23
  # Returns nothing.
23
24
  def run
24
- return unless @mapping.root?
25
+ return if @strategy.passive?
25
26
 
26
27
  @runner.run(@mapping) do |run|
27
28
  run.salesforce_records { |record| create_in_database(record) }
@@ -39,7 +40,7 @@ module Restforce
39
40
  #
40
41
  # Returns nothing.
41
42
  def create_in_database(record)
42
- return if record.synced?
43
+ return unless @strategy.build?(record)
43
44
  @mapping.database_record_type.create!(record)
44
45
  end
45
46
 
@@ -9,57 +9,9 @@ module Restforce
9
9
 
10
10
  class InvalidMappingError < StandardError; end
11
11
 
12
- class << self
13
-
14
- include Enumerable
15
- attr_accessor :collection
16
-
17
- # Public: Get the Restforce::DB::Mapping entry for the specified model.
18
- #
19
- # model - A String or Class.
20
- #
21
- # Returns a Restforce::DB::Mapping.
22
- def [](model)
23
- collection[model]
24
- end
25
-
26
- # Public: Iterate through all registered Restforce::DB::Mappings.
27
- #
28
- # Yields one Mapping for each database-to-Salesforce mapping.
29
- # Returns nothing.
30
- def each
31
- collection.each do |model, mappings|
32
- # Since each mapping is inserted twice, we ignore the half which
33
- # were inserted via Salesforce model names.
34
- next unless model.is_a?(Class)
35
-
36
- mappings.each do |mapping|
37
- yield mapping
38
- end
39
- end
40
- end
41
-
42
- # Public: Add a mapping to the overarching Mapping collection. Appends
43
- # the mapping to the collection for both its database and salesforce
44
- # object types.
45
- #
46
- # mapping - A Restforce::DB::Mapping.
47
- #
48
- # Returns nothing.
49
- def <<(mapping)
50
- [mapping.database_model, mapping.salesforce_model].each do |model|
51
- collection[model] ||= []
52
- collection[model] << mapping
53
- end
54
- end
55
-
56
- end
57
-
58
- self.collection ||= {}
59
-
60
12
  extend Forwardable
61
13
  def_delegators(
62
- :@attribute_map,
14
+ :attribute_map,
63
15
  :attributes,
64
16
  :convert,
65
17
  :convert_from_salesforce,
@@ -70,42 +22,32 @@ module Restforce
70
22
  :salesforce_model,
71
23
  :database_record_type,
72
24
  :salesforce_record_type,
25
+ )
26
+
27
+ attr_accessor(
28
+ :fields,
73
29
  :associations,
74
30
  :conditions,
75
31
  :through,
32
+ :strategy,
76
33
  )
77
34
 
78
35
  # Public: Initialize a new Restforce::DB::Mapping.
79
36
  #
80
37
  # database_model - A Class compatible with ActiveRecord::Base.
81
38
  # salesforce_model - A String name of an object type in Salesforce.
82
- # options - A Hash of mapping attributes. Currently supported
83
- # keys are:
84
- # :fields - A Hash of mappings between database
85
- # columns and fields in Salesforce.
86
- # :associations - A Hash of mappings between Active
87
- # Record association names and the
88
- # corresponding Salesforce Lookup name.
89
- # :conditions - An Array of lookup conditions which
90
- # should be applied to the Salesforce
91
- # queries.
92
- # :root - A Boolean reflecting whether or not
93
- # this is a root-level mapping.
94
- def initialize(database_model, salesforce_model, options = {})
39
+ # strategy - A synchronization Strategy object.
40
+ def initialize(database_model, salesforce_model, strategy = Strategies::Always.new)
95
41
  @database_model = database_model
96
42
  @salesforce_model = salesforce_model
97
43
 
98
44
  @database_record_type = RecordTypes::ActiveRecord.new(database_model, self)
99
45
  @salesforce_record_type = RecordTypes::Salesforce.new(salesforce_model, self)
100
46
 
101
- @fields = options.fetch(:fields) { {} }
102
- @associations = options.fetch(:associations) { {} }
103
- @conditions = options.fetch(:conditions) { [] }
104
- @through = options.fetch(:through) { nil }
105
-
106
- @attribute_map = AttributeMap.new(database_model, salesforce_model, @fields)
107
-
108
- self.class << self
47
+ self.associations = {}
48
+ self.fields = {}
49
+ self.conditions = []
50
+ self.strategy = strategy
109
51
  end
110
52
 
111
53
  # Public: Get a list of the relevant Salesforce field names for this
@@ -113,7 +55,7 @@ module Restforce
113
55
  #
114
56
  # Returns an Array.
115
57
  def salesforce_fields
116
- @fields.values + @associations.values.flatten
58
+ fields.values + associations.values.flatten
117
59
  end
118
60
 
119
61
  # Public: Get a list of the relevant database column names for this
@@ -121,7 +63,7 @@ module Restforce
121
63
  #
122
64
  # Returns an Array.
123
65
  def database_fields
124
- @fields.keys
66
+ fields.keys
125
67
  end
126
68
 
127
69
  # Public: Get the name of the database column which should be used to
@@ -144,12 +86,13 @@ module Restforce
144
86
  end
145
87
  end
146
88
 
147
- # Public: Is this a root-level mapping? Used to determine whether or not
148
- # to trigger the creation of "missing" database records.
89
+ private
90
+
91
+ # Internal: Get an AttributeMap for the fields defined for this mapping.
149
92
  #
150
- # Returns a Boolean.
151
- def root?
152
- @through.nil?
93
+ # Returns a Restforce::DB::AttributeMap.
94
+ def attribute_map
95
+ @attribute_map ||= AttributeMap.new(database_model, salesforce_model, fields)
153
96
  end
154
97
 
155
98
  end
@@ -16,14 +16,17 @@ module Restforce
16
16
  module ClassMethods
17
17
 
18
18
  # Public: Initializes a Restforce::DB::Mapping defining this model's
19
- # relationship to a Salesforce object type.
19
+ # relationship to a Salesforce object type. Passes a provided block to
20
+ # the Restforce::DB::DSL for evaluation.
20
21
  #
21
22
  # salesforce_model - A String name of an object type in Salesforce.
23
+ # strategy - A Symbol naming a desired initialization strategy.
22
24
  # options - A Hash of options to pass through to the Mapping.
25
+ # block - A block of code to evaluate through the DSL.
23
26
  #
24
- # Returns a Restforce::DB::Mapping.
25
- def sync_with(salesforce_model, **options)
26
- Mapping.new(self, salesforce_model, options)
27
+ # Returns nothing.
28
+ def sync_with(salesforce_model, strategy = :always, options = {}, &block)
29
+ Restforce::DB::DSL.new(self, salesforce_model, strategy, options).instance_eval(&block)
27
30
  end
28
31
 
29
32
  end