restforce-db 3.0.0 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/lib/restforce/db.rb +2 -0
  3. data/lib/restforce/db/associator.rb +2 -11
  4. data/lib/restforce/db/attacher.rb +91 -0
  5. data/lib/restforce/db/cleaner.rb +3 -13
  6. data/lib/restforce/db/collector.rb +1 -10
  7. data/lib/restforce/db/field_processor.rb +53 -17
  8. data/lib/restforce/db/initializer.rb +6 -14
  9. data/lib/restforce/db/instances/salesforce.rb +2 -1
  10. data/lib/restforce/db/record_types/salesforce.rb +47 -20
  11. data/lib/restforce/db/synchronizer.rb +1 -9
  12. data/lib/restforce/db/task.rb +32 -0
  13. data/lib/restforce/db/version.rb +1 -1
  14. data/lib/restforce/db/worker.rb +21 -59
  15. data/test/cassettes/Restforce_DB/accessing_Salesforce/uses_the_configured_credentials.yml +6 -6
  16. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_build/returns_an_associated_record_populated_with_the_Salesforce_attributes.yml +203 -52
  17. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_build/when_no_salesforce_record_is_found_for_the_association/proceeds_without_constructing_any_records.yml +185 -34
  18. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_build/when_the_associated_record_has_already_been_persisted/assigns_the_existing_record.yml +203 -52
  19. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_build/when_the_associated_record_has_been_cached/uses_the_cached_record.yml +203 -52
  20. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_build/when_the_association_is_non-building/proceeds_without_constructing_any_records.yml +97 -44
  21. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_build/with_an_unrelated_association_mapping/proceeds_without_raising_an_error.yml +203 -52
  22. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_lookups/returns_a_hash_of_the_associated_records_lookup_IDs.yml +36 -36
  23. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_lookups/when_there_is_currently_no_associated_record/and_the_underlying_association_is_one-to-many/still_returns_a_nil_lookup_value_in_the_hash.yml +20 -20
  24. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_lookups/when_there_is_currently_no_associated_record/returns_a_nil_lookup_value_in_the_hash.yml +20 -20
  25. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_synced_for_/when_a_matching_associated_record_has_been_synchronized/returns_true.yml +105 -52
  26. data/test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_synced_for_/when_no_matching_associated_record_has_been_synchronized/returns_false.yml +105 -52
  27. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_build/builds_a_number_of_associated_records_from_the_data_in_Salesforce.yml +191 -87
  28. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_build/when_no_salesforce_record_is_found_for_the_association/proceeds_without_constructing_any_records.yml +139 -35
  29. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_build/when_the_associated_records_have_alrady_been_persisted/constructs_the_association_from_the_existing_records.yml +191 -87
  30. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_build/when_the_associated_records_have_been_cached/uses_the_cached_records.yml +191 -87
  31. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_build/when_the_association_is_non-building/proceeds_without_constructing_any_records.yml +81 -28
  32. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_synced_for_/when_a_matching_associated_record_has_been_synchronized/returns_true.yml +191 -87
  33. data/test/cassettes/Restforce_DB_Associations_HasMany/with_an_inverse_mapping/_synced_for_/when_no_matching_associated_record_has_been_synchronized/returns_false.yml +139 -35
  34. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_build/and_a_nested_association_on_the_associated_mapping/recursively_builds_all_associations.yml +278 -76
  35. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_build/returns_an_associated_record_populated_with_the_Salesforce_attributes.yml +203 -52
  36. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_build/when_no_salesforce_record_is_found_for_the_association/proceeds_without_constructing_any_records.yml +186 -35
  37. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_build/when_the_associated_record_has_already_been_persisted/assigns_the_existing_record.yml +203 -52
  38. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_build/when_the_associated_record_has_been_cached/uses_the_cached_record.yml +203 -52
  39. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_build/when_the_association_is_non-building/proceeds_without_constructing_any_records.yml +142 -44
  40. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_synced_for_/when_a_matching_associated_record_has_been_synchronized/returns_true.yml +203 -52
  41. data/test/cassettes/Restforce_DB_Associations_HasOne/with_an_inverse_mapping/_synced_for_/when_no_matching_associated_record_has_been_synchronized/returns_false.yml +203 -52
  42. data/test/cassettes/Restforce_DB_Associator/_run/given_a_BelongsTo_association/given_another_record_for_association/when_the_Salesforce_association_is_out_of_date/updates_the_association_ID_in_Salesforce.yml +124 -124
  43. data/test/cassettes/Restforce_DB_Associator/_run/given_a_BelongsTo_association/given_another_record_for_association/when_the_database_association_is_out_of_date/updates_the_associated_record_in_the_database.yml +224 -126
  44. data/test/cassettes/Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_a_Passive_strategy/does_nothing.yml +119 -0
  45. data/test/cassettes/Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_an_Always_strategy/links_the_Salesforce_record_to_the_matching_database_record.yml +246 -0
  46. data/test/cassettes/Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_an_Always_strategy/when_no_matching_database_record_can_be_found/wipes_the_SynchronizationID__c_on_the_Salesforce_record.yml +284 -0
  47. data/test/cassettes/Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_an_Always_strategy/when_the_matching_database_record_has_a_salesforce_id/does_not_change_the_current_Salesforce_ID.yml +246 -0
  48. data/test/cassettes/Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_an_Always_strategy/when_the_matching_database_record_has_a_salesforce_id/wipes_the_SynchronizationId__c.yml +284 -0
  49. data/test/cassettes/{Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_an_associated_database_record/updates_the_salesforce_record.yml → Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_an_Always_strategy/when_the_upsert_ID_is_for_another_database_model/does_not_wipe_the_SynchronizationId__c.yml} +57 -95
  50. data/test/cassettes/{Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_an_associated_database_record/updates_the_database_record.yml → Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_an_Always_strategy/wipes_the_SynchronizationId__c.yml} +77 -116
  51. data/test/cassettes/Restforce_DB_Cleaner/_run/given_a_synchronized_Salesforce_record/when_the_mapping_has_no_conditions/does_not_drop_the_synchronized_database_record.yml +20 -20
  52. data/test/cassettes/Restforce_DB_Cleaner/_run/given_a_synchronized_Salesforce_record/when_the_record_does_not_meet_the_mapping_conditions/but_meets_conditions_for_a_parallel_mapping/does_not_drop_the_synchronized_database_record.yml +98 -45
  53. data/test/cassettes/Restforce_DB_Cleaner/_run/given_a_synchronized_Salesforce_record/when_the_record_does_not_meet_the_mapping_conditions/drops_the_synchronized_database_record.yml +90 -37
  54. data/test/cassettes/Restforce_DB_Cleaner/_run/given_a_synchronized_Salesforce_record/when_the_record_has_been_deleted_in_Salesforce/drops_the_synchronized_database_record.yml +20 -20
  55. data/test/cassettes/Restforce_DB_Cleaner/_run/given_a_synchronized_Salesforce_record/when_the_record_meets_the_mapping_conditions/does_not_drop_the_synchronized_database_record.yml +89 -36
  56. data/test/cassettes/Restforce_DB_Collector/_run/given_a_Salesforce_record_with_an_associated_database_record/returns_the_attributes_from_both_records.yml +103 -50
  57. data/test/cassettes/Restforce_DB_Collector/_run/given_an_existing_Salesforce_record/which_has_been_synchronized/returns_the_attributes_from_the_Salesforce_record.yml +102 -73
  58. data/test/cassettes/Restforce_DB_Collector/_run/given_an_existing_Salesforce_record/which_has_not_been_synchronized/does_not_store_any_attributes.yml +81 -40
  59. data/test/cassettes/Restforce_DB_Collector/_run/given_an_existing_database_record/returns_the_attributes_from_the_database_record.yml +66 -13
  60. data/test/cassettes/Restforce_DB_Collector/_run/when_the_record_has_not_been_updated_outside_of_the_system/does_not_collect_any_changes.yml +82 -29
  61. data/test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_Salesforce_record/for_a_Passive_strategy/does_not_create_a_database_record.yml +20 -20
  62. data/test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_Salesforce_record/for_an_Always_strategy/creates_a_matching_database_record.yml +81 -28
  63. data/test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_database_record/for_an_Always_strategy/populates_Salesforce_with_the_new_record.yml +103 -67
  64. data/test/cassettes/Restforce_DB_Instances_Salesforce/_synced_/when_a_matching_database_record_exists/returns_true.yml +81 -28
  65. data/test/cassettes/Restforce_DB_Instances_Salesforce/_synced_/when_no_matching_database_record_exists/returns_false.yml +81 -28
  66. data/test/cassettes/Restforce_DB_Instances_Salesforce/_update_/updates_the_local_record_with_the_passed_attributes.yml +59 -59
  67. data/test/cassettes/Restforce_DB_Instances_Salesforce/_update_/updates_the_record_in_Salesforce_with_the_passed_attributes.yml +67 -67
  68. data/test/cassettes/Restforce_DB_Instances_Salesforce/_updated_internally_/when_another_user_made_the_last_change/returns_false.yml +18 -18
  69. data/test/cassettes/Restforce_DB_Instances_Salesforce/_updated_internally_/when_our_client_made_the_last_change/returns_true.yml +101 -48
  70. data/test/cassettes/Restforce_DB_Model/given_a_database_model_which_includes_the_module/_force_sync_/given_a_previously-synchronized_record_for_a_mapped_model/force-updates_both_synchronized_records.yml +67 -67
  71. data/test/cassettes/Restforce_DB_Model/given_a_database_model_which_includes_the_module/_force_sync_/given_an_unsynchronized_record_for_a_mapped_model/creates_a_matching_record_in_Salesforce.yml +78 -42
  72. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_all/returns_a_list_of_the_existing_records_in_Salesforce.yml +82 -29
  73. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_create_/creates_a_record_in_Salesforce_from_the_passed_database_record_s_attributes.yml +70 -34
  74. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_create_/updates_the_database_record_with_the_Salesforce_record_s_ID.yml +70 -34
  75. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_create_/when_a_Salesforce_record_already_exists_for_the_database_instance/uses_the_existing_record.yml +76 -40
  76. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_create_/wipes_the_temporary_SynchronizationId__c_value_used_for_upsert.yml +247 -0
  77. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_find/finds_existing_records_in_Salesforce.yml +81 -28
  78. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_find/given_a_set_of_mapping_conditions/when_a_record_does_not_meet_the_conditions/does_not_find_the_record.yml +80 -27
  79. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_find/given_a_set_of_mapping_conditions/when_a_record_meets_the_conditions/finds_the_record.yml +81 -28
  80. data/test/cassettes/Restforce_DB_RecordTypes_Salesforce/_find/returns_nil_when_no_matching_record_exists.yml +65 -12
  81. data/test/cassettes/Restforce_DB_Strategies_Always/_build_/given_a_Salesforce_record/wants_to_build_a_new_matching_record.yml +81 -28
  82. 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 +81 -28
  83. data/test/cassettes/Restforce_DB_Strategies_Associated/_build_/given_an_inverse_mapping/with_a_synchronized_association_record/wants_to_build_a_new_record.yml +103 -52
  84. data/test/cassettes/Restforce_DB_Strategies_Associated/_build_/given_an_inverse_mapping/with_an_existing_database_record/does_not_want_to_build_a_new_record.yml +95 -44
  85. data/test/cassettes/Restforce_DB_Strategies_Associated/_build_/given_an_inverse_mapping/with_no_synchronized_association_record/does_not_want_to_build_a_new_record.yml +103 -52
  86. data/test/cassettes/Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_an_associated_database_record/when_the_change_timestamp_is_stale/does_not_update_the_database_record.yml +60 -96
  87. data/test/cassettes/Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_an_associated_database_record/when_the_change_timestamp_is_stale/does_not_update_the_salesforce_record.yml +97 -44
  88. data/test/cassettes/Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_an_associated_database_record/when_the_changes_are_current/updates_the_database_record.yml +77 -77
  89. data/test/cassettes/Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_an_associated_database_record/when_the_changes_are_current/updates_the_salesforce_record.yml +85 -85
  90. data/test/cassettes/Restforce_DB_Synchronizer/_run/given_a_Salesforce_record_with_no_associated_database_record/does_nothing_for_this_specific_mapping.yml +89 -36
  91. data/test/cassettes/Restforce_DB_Worker/a_race_condition_during_synchronization/does_not_change_the_user-entered_name_on_the_database_record.yml +153 -116
  92. data/test/cassettes/Restforce_DB_Worker/a_race_condition_during_synchronization/overrides_the_stale-but-more-recent_name_on_the_Salesforce.yml +161 -124
  93. data/test/lib/restforce/db/attacher_test.rb +93 -0
  94. data/test/lib/restforce/db/field_processor_test.rb +62 -23
  95. data/test/lib/restforce/db/record_types/salesforce_test.rb +5 -0
  96. data/test/lib/restforce/db/worker_test.rb +4 -3
  97. metadata +13 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ed593fdcf73e145f3957db532c939650f7112b77
4
- data.tar.gz: 2a20acce1c5b9d85e717d6dc33d9c1e85a1c392d
3
+ metadata.gz: 008721b2ee67237a7bbcf0d8c8cdc9353fcbceb8
4
+ data.tar.gz: 25ddc50b58f134522817748ddbb36ba32dc57fa2
5
5
  SHA512:
6
- metadata.gz: 2f875a2b2f3f88717e2fd3ef74d842f25fc7b0bbc11726ee33ed3e91ed26c0744f28d647aa5d04c4a5bf548665f19fdc102462a84474ef694687d0df26d7ad4e
7
- data.tar.gz: 0fab5908d5a1ae144f8563c4a27fb02f03004a932ac4044316d17f442047b0f170529ccf457ee6600e3a87fb5b0acdb606a18d56dd78804a9448bd54ee389c9d
6
+ metadata.gz: 97eb6aea0ac0e2fd2d5a6e8b5f41b3f7b7600c5a0da646519dea32e957e556081edabb72e1eef7639ed7df7b31e747985d7c87a9425bdb383520fb081dae1f9d
7
+ data.tar.gz: ba0c3c6772e0d7b60b5403530008b492997b0cdc98b2fe53fb0dcd8fe0b248d6e8439c6dda2689f2b42c4c988e2296e37bd06107db331e643349a380875b0107
data/lib/restforce/db.rb CHANGED
@@ -35,7 +35,9 @@ require "restforce/db/record_cache"
35
35
  require "restforce/db/timestamp_cache"
36
36
  require "restforce/db/runner"
37
37
 
38
+ require "restforce/db/task"
38
39
  require "restforce/db/accumulator"
40
+ require "restforce/db/attacher"
39
41
  require "restforce/db/adapter"
40
42
  require "restforce/db/associator"
41
43
  require "restforce/db/attribute_map"
@@ -6,23 +6,14 @@ module Restforce
6
6
  # associations have been updated to point to a new Salesforce/database
7
7
  # record, and propagate the modification to the opposite system when this
8
8
  # occurs.
9
- class Associator
10
-
11
- # Public: Initialize a new Restforce::DB::Associator.
12
- #
13
- # mapping - A Restforce::DB::Mapping instance.
14
- # runner - A Restforce::DB::Runner instance.
15
- def initialize(mapping, runner = Runner.new)
16
- @mapping = mapping
17
- @runner = runner
18
- end
9
+ class Associator < Task
19
10
 
20
11
  # Public: Run the re-association process, pulling in records from
21
12
  # Salesforce and the database to determine the most recently attached
22
13
  # association, then propagating the change between systems.
23
14
  #
24
15
  # Returns nothing.
25
- def run
16
+ def run(*_)
26
17
  return if belongs_to_associations.empty?
27
18
 
28
19
  @runner.run(@mapping) do |run|
@@ -0,0 +1,91 @@
1
+ module Restforce
2
+
3
+ module DB
4
+
5
+ # Restforce::DB::Attacher is responsible for cleaning up any orphaned
6
+ # upserted Salesforce records with the database records that they were
7
+ # originally supposed to be attached to. This allows us to successfully
8
+ # handle cases where a request timeout or partial failure would otherwise
9
+ # leave an upserted Salesforce record stranded without a related database
10
+ # record.
11
+ class Attacher < Task
12
+
13
+ # Public: Run the re-attachment process for any unsynchronized Salesforce
14
+ # records which have an external upsert ID.
15
+ #
16
+ # Returns nothing.
17
+ def run(*_)
18
+ return if @mapping.strategy.passive?
19
+
20
+ @runner.run(@mapping) do |run|
21
+ run.salesforce_instances.each { |instance| attach(instance) }
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Internal: Attach the passed Salesforce instance to a related database
28
+ # record.
29
+ #
30
+ # instance - A Restforce::DB::Instances::Salesforce.
31
+ #
32
+ # Returns nothing.
33
+ def attach(instance)
34
+ synchronization_id = instance.record.SynchronizationId__c
35
+ return unless synchronization_id
36
+
37
+ database_model, record_id = parsed_uuid(synchronization_id)
38
+ return unless valid_model?(database_model)
39
+
40
+ # If the instance is already synchronized, then we just want to wipe the
41
+ # Synchronization ID and proceed to the next instance.
42
+ if instance.synced?
43
+ instance.update!("SynchronizationId__c" => nil)
44
+ return
45
+ end
46
+
47
+ record = @mapping.database_model.find_by(
48
+ id: record_id,
49
+ @mapping.lookup_column => nil,
50
+ )
51
+
52
+ if record
53
+ attach_to = Instances::ActiveRecord.new(@mapping.database_model, record, @mapping)
54
+ attach_to.update!(@mapping.lookup_column => instance.id)
55
+ end
56
+
57
+ instance.update!("SynchronizationId__c" => nil)
58
+ rescue Faraday::Error::ClientError => e
59
+ DB.logger.error(SynchronizationError.new(e, instance))
60
+ end
61
+
62
+ # Internal: Does the passed database model correspond to the model defined
63
+ # on the mapping?
64
+ #
65
+ # database_model - A String name of an ActiveRecord::Base subclass.
66
+ #
67
+ # Returns a Boolean.
68
+ def valid_model?(database_model)
69
+ database_model == @mapping.database_model.to_s
70
+ end
71
+
72
+ # Internal: Parse a UUID into a database model and corresponding record
73
+ # identifier.
74
+ #
75
+ # uuid - A String UUID, in the format "<Model>::<Id>"
76
+ #
77
+ # Returns an Array of two Strings.
78
+ def parsed_uuid(uuid)
79
+ components = uuid.split("::")
80
+
81
+ database_model = components[0..-2].join("::")
82
+ id = components.last
83
+
84
+ [database_model, id]
85
+ end
86
+
87
+ end
88
+
89
+ end
90
+
91
+ end
@@ -5,27 +5,17 @@ module Restforce
5
5
  # Restforce::DB::Cleaner is responsible for culling the matching database
6
6
  # records when a Salesforce record is no longer available to synchronize
7
7
  # for a specific mapping.
8
- class Cleaner
8
+ class Cleaner < Task
9
9
 
10
10
  # Salesforce can take a few minutes to register record deletion. This
11
11
  # buffer gives us a window of time (in seconds) to look back and see
12
12
  # records which may not have been visible in previous runs.
13
13
  DELETION_READ_BUFFER = 3 * 60
14
14
 
15
- # Public: Initialize a Restforce::DB::Cleaner.
16
- #
17
- # mapping - A Restforce::DB::Mapping.
18
- # runner - A Restforce::DB::Runner.
19
- def initialize(mapping, runner = Runner.new)
20
- @mapping = mapping
21
- @strategy = mapping.strategy
22
- @runner = runner
23
- end
24
-
25
15
  # Public: Run the database culling loop for this mapping.
26
16
  #
27
17
  # Returns nothing.
28
- def run
18
+ def run(*_)
29
19
  @mapping.database_record_type.destroy_all(dropped_salesforce_ids)
30
20
  end
31
21
 
@@ -61,7 +51,7 @@ module Restforce
61
51
  #
62
52
  # Returns an Array of IDs.
63
53
  def invalid_salesforce_ids
64
- return [] if @mapping.conditions.empty? || @strategy.passive?
54
+ return [] if @mapping.conditions.empty? || @mapping.strategy.passive?
65
55
 
66
56
  all_salesforce_ids - valid_salesforce_ids
67
57
  end
@@ -6,16 +6,7 @@ module Restforce
6
6
  # recently-updated records for purposes of synchronization. It relies on the
7
7
  # mappings configured in instances of Restforce::DB::RecordTypes::Base to
8
8
  # locate recently-updated records and fetch their attributes.
9
- class Collector
10
-
11
- # Public: Initialize a new Restforce::DB::Collector.
12
- #
13
- # mapping - A Restforce::DB::Mapping instance.
14
- # runner - A Restforce::DB::Runner instance.
15
- def initialize(mapping, runner = Runner.new)
16
- @mapping = mapping
17
- @runner = runner
18
- end
9
+ class Collector < Task
19
10
 
20
11
  # Public: Run the collection process, pulling in records from Salesforce
21
12
  # and the database to determine the lists of attributes to apply to all
@@ -6,6 +6,10 @@ module Restforce
6
6
  # information for unwriteable fields from being submitted to Salesforce.
7
7
  class FieldProcessor
8
8
 
9
+ # This token indicates that a relationship is being accessed for a
10
+ # specific field.
11
+ RELATIONSHIP_MATCHER = /(.+)__r\./.freeze
12
+
9
13
  # Internal: Get a global cache with which to store/fetch the writable
10
14
  # fields for each Salesforce SObject Type.
11
15
  #
@@ -21,52 +25,84 @@ module Restforce
21
25
  @field_cache = {}
22
26
  end
23
27
 
24
- # Public: Get a restricted version of the passed attributes Hash, with
25
- # unwritable fields stripped out.
28
+ # Public: Get a list of valid fields for a specific action from the passed
29
+ # list of proposed fields. Allows access to related object fields on a
30
+ # read-only basis.
26
31
  #
27
32
  # sobject_type - A String name of an SObject Type in Salesforce.
28
33
  # attributes - A Hash with keys corresponding to Salesforce field names.
29
- # write_type - A Symbol reflecting the type of write operation. Accepted
30
- # values are :create and :update. Defaults to :update.
34
+ # action - A Symbol reflecting the action to perform. Accepted
35
+ # values are :read, :create, and :update.
31
36
  #
32
37
  # Returns a Hash.
33
- def process(sobject_type, attributes, write_type = :update)
34
- attributes.each_with_object({}) do |(field, value), processed|
35
- next unless writable?(sobject_type, field, write_type)
36
- processed[field] = value
38
+ def available_fields(sobject_type, fields, action = :read)
39
+ fields.select do |field|
40
+ known_field = available?(sobject_type, field, action)
41
+ relationship = action == :read && relationship?(field)
42
+
43
+ known_field || relationship
37
44
  end
38
45
  end
39
46
 
47
+ # Public: Get a restricted version of the passed attributes Hash, with
48
+ # inaccessible fields for the specified action stripped out.
49
+ #
50
+ # sobject_type - A String name of an SObject Type in Salesforce.
51
+ # attributes - A Hash with keys corresponding to Salesforce field names.
52
+ # action - A Symbol reflecting the action to perform. Accepted
53
+ # values are :create and :update.
54
+ #
55
+ # Returns a Hash.
56
+ def process(sobject_type, attributes, action)
57
+ attributes.select { |field, _| available?(sobject_type, field, action) }
58
+ end
59
+
40
60
  private
41
61
 
42
- # Internal: Is the passed attribute writable for the passed SObject Type?
62
+ # Internal: Is the passed attribute available for the specified action on
63
+ # the passed SObject Type?
43
64
  #
44
65
  # sobject_type - A String name of an SObject Type in Salesforce.
45
66
  # field - A String Salesforce field API name.
46
- # write_type - A Symbol reflecting the type of write operation. Accepted
47
- # values are :create and :update.
67
+ # action - A Symbol reflecting the requested action. Accepted values
68
+ # are :read, :create, and :update.
48
69
  #
49
70
  # Returns a Boolean.
50
- def writable?(sobject_type, field, write_type)
51
- permissions = field_permissions(sobject_type)[field]
71
+ def available?(sobject_type, field, action)
72
+ permissions = field_metadata(sobject_type)[field]
52
73
  return false unless permissions
53
74
 
54
- permissions[write_type]
75
+ permissions[action]
76
+ end
77
+
78
+ # Internal: Does the passed field description reference an attribute
79
+ # through an associated object?
80
+ #
81
+ # NOTE: It's not worth the trouble to validate that this relationship
82
+ # actually exists, or that the requested field exists on the related
83
+ # model. If a bad lookup is specified, the API will throw an error.
84
+ #
85
+ # field - A String Salesforce field API name.
86
+ #
87
+ # Rturns a Boolean.
88
+ def relationship?(field)
89
+ field =~ RELATIONSHIP_MATCHER
55
90
  end
56
91
 
57
92
  # Internal: Get a collection of all fields for the passed Salesforce
58
- # SObject Type, with an indication of whether or not they are writable for
59
- # both create and update actions.
93
+ # SObject Type, with an indication of whether or not they are readable and
94
+ # writable for both create and update actions.
60
95
  #
61
96
  # sobject_type - A String name of an SObject Type in Salesforce.
62
97
  #
63
98
  # Returns a Hash.
64
- def field_permissions(sobject_type)
99
+ def field_metadata(sobject_type)
65
100
  self.class.field_cache[sobject_type] ||= begin
66
101
  fields = Restforce::DB.client.describe(sobject_type).fields
67
102
 
68
103
  fields.each_with_object({}) do |field, permissions|
69
104
  permissions[field["name"]] = {
105
+ read: true,
70
106
  create: field["createable"],
71
107
  update: field["updateable"],
72
108
  }
@@ -6,23 +6,13 @@ module Restforce
6
6
  # are populated with the same records at the root level. It iterates through
7
7
  # recently added or updated records in each system for a mapping, and
8
8
  # creates a matching record in the other system, when necessary.
9
- class Initializer
10
-
11
- # Public: Initialize a Restforce::DB::Initializer.
12
- #
13
- # mapping - A Restforce::DB::Mapping.
14
- # runner - A Restforce::DB::Runner.
15
- def initialize(mapping, runner = Runner.new)
16
- @mapping = mapping
17
- @strategy = mapping.strategy
18
- @runner = runner
19
- end
9
+ class Initializer < Task
20
10
 
21
11
  # Public: Run the initialization loop for this mapping.
22
12
  #
23
13
  # Returns nothing.
24
- def run
25
- return if @strategy.passive?
14
+ def run(*_)
15
+ return if @mapping.strategy.passive?
26
16
 
27
17
  @runner.run(@mapping) do |run|
28
18
  run.salesforce_instances.each { |instance| create_in_database(instance) }
@@ -40,7 +30,8 @@ module Restforce
40
30
  #
41
31
  # Returns nothing.
42
32
  def create_in_database(instance)
43
- return unless @strategy.build?(instance)
33
+ return unless @mapping.strategy.build?(instance)
34
+
44
35
  created = @mapping.database_record_type.create!(instance)
45
36
  @runner.cache_timestamp created
46
37
  rescue ActiveRecord::ActiveRecordError => e
@@ -56,6 +47,7 @@ module Restforce
56
47
  # Returns nothing.
57
48
  def create_in_salesforce(instance)
58
49
  return if instance.synced?
50
+
59
51
  created = @mapping.salesforce_record_type.create!(instance)
60
52
  @runner.cache_timestamp created
61
53
  rescue Faraday::Error::ClientError => e
@@ -11,6 +11,7 @@ module Restforce
11
11
 
12
12
  INTERNAL_ATTRIBUTES = %w(
13
13
  Id
14
+ SynchronizationId__c
14
15
  SystemModstamp
15
16
  LastModifiedById
16
17
  ).freeze
@@ -29,7 +30,7 @@ module Restforce
29
30
  # Returns self.
30
31
  # Raises if the update fails for any reason.
31
32
  def update!(attributes)
32
- super FieldProcessor.new.process(@record_type, attributes)
33
+ super FieldProcessor.new.process(@record_type, attributes, :update)
33
34
  end
34
35
 
35
36
  # Public: Get the time of the last update to this record.
@@ -9,6 +9,8 @@ module Restforce
9
9
  # attribute mappings.
10
10
  class Salesforce < Base
11
11
 
12
+ ALREADY_EXISTS_MESSAGE = /INVALID_FIELD_FOR_INSERT_UPDATE|DUPLICATE_VALUE/.freeze
13
+
12
14
  # Public: Create an instance of this Salesforce model for the passed
13
15
  # database record.
14
16
  #
@@ -17,24 +19,19 @@ module Restforce
17
19
  # Returns a Restforce::DB::Instances::Salesforce instance.
18
20
  # Raises on any error from Salesforce.
19
21
  def create!(from_record)
20
- from_attributes = FieldProcessor.new.process(@record_type, from_record.attributes, :create)
21
- record_id = DB.client.upsert!(
22
- @record_type,
23
- "SynchronizationId__c",
24
- from_attributes.merge("SynchronizationId__c" => from_record.uuid),
25
- )
22
+ record_id = upsert!(from_record)
26
23
 
27
24
  # NOTE: #upsert! returns a String Salesforce ID when a record is
28
- # created, or returns `true` when an existing record was found and
29
- # updated.
30
- if record_id.is_a?(String)
31
- from_record.update!(@mapping.lookup_column => record_id).after_sync
32
- find(record_id)
33
- else
34
- instance = first("SynchronizationId__c = '#{from_record.uuid}'")
35
- from_record.update!(@mapping.lookup_column => instance.id).after_sync
36
- instance
37
- end
25
+ # created, and returns `true` when an existing record was found.
26
+ instance =
27
+ if record_id.is_a?(String)
28
+ find(record_id)
29
+ else
30
+ first("SynchronizationId__c = '#{from_record.uuid}'")
31
+ end
32
+
33
+ from_record.update!(@mapping.lookup_column => instance.id)
34
+ instance.update!("SynchronizationId__c" => nil)
38
35
  end
39
36
 
40
37
  # Public: Find the first Salesforce record which meets the passed
@@ -88,13 +85,43 @@ module Restforce
88
85
 
89
86
  private
90
87
 
91
- # Internal: Get a String of values to look up when the record is
88
+ # Internal: Get a list of fields to look up when the record is
92
89
  # fetched from Salesforce. Includes all configured mappings and a
93
90
  # handful of attributes for internal use.
94
91
  #
95
- # Returns a String.
92
+ # Returns an Array of Strings.
96
93
  def lookups
97
- (Instances::Salesforce::INTERNAL_ATTRIBUTES + @mapping.salesforce_fields).uniq.join(", ")
94
+ FieldProcessor.new.available_fields(
95
+ @record_type,
96
+ (Instances::Salesforce::INTERNAL_ATTRIBUTES + @mapping.salesforce_fields).uniq,
97
+ )
98
+ end
99
+
100
+ # Internal: Attempt to create a record in Salesforce from the passed
101
+ # database instance.
102
+ #
103
+ # Returns a String or Boolean.
104
+ def upsert!(from_record)
105
+ from_attributes = FieldProcessor.new.process(
106
+ @record_type,
107
+ from_record.attributes,
108
+ :create,
109
+ )
110
+
111
+ DB.client.upsert!(
112
+ @record_type,
113
+ "SynchronizationId__c",
114
+ from_attributes.merge("SynchronizationId__c" => from_record.uuid),
115
+ )
116
+ rescue Faraday::Error::ClientError => e
117
+ # If the error is complaining about attempting to update create-only
118
+ # fields, we've confirmed that the record already exists, and can
119
+ # safely resolve our object creation.
120
+ if e.message =~ ALREADY_EXISTS_MESSAGE
121
+ true
122
+ else
123
+ raise e
124
+ end
98
125
  end
99
126
 
100
127
  # Internal: Has this database record already been linked to a Salesforce
@@ -116,7 +143,7 @@ module Restforce
116
143
  filters = (conditions + @mapping.conditions).compact.join(" and ")
117
144
  filters = " where #{filters}" unless filters.empty?
118
145
 
119
- "select #{lookups} from #{@record_type}#{filters}"
146
+ "select #{lookups.join(', ')} from #{@record_type}#{filters}"
120
147
  end
121
148
 
122
149
  end