restforce-db 3.0.0 → 3.1.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 (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