├── .gitignore ├── .rubocop.yml ├── .rubocop └── custom │ └── method_documentation.rb ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── circle.yml ├── lib ├── file_daemon.rb ├── forked_process.rb ├── generators │ ├── restforce │ │ ├── install_generator.rb │ │ └── migration_generator.rb │ └── templates │ │ ├── config.yml │ │ ├── migration.rb.tt │ │ └── script ├── restforce │ ├── db.rb │ ├── db │ │ ├── accumulator.rb │ │ ├── adapter.rb │ │ ├── association_cache.rb │ │ ├── associations │ │ │ ├── base.rb │ │ │ ├── belongs_to.rb │ │ │ ├── foreign_key.rb │ │ │ ├── has_many.rb │ │ │ └── has_one.rb │ │ ├── associator.rb │ │ ├── attacher.rb │ │ ├── attribute_map.rb │ │ ├── attribute_maps │ │ │ ├── database.rb │ │ │ └── salesforce.rb │ │ ├── cleaner.rb │ │ ├── client.rb │ │ ├── collector.rb │ │ ├── command.rb │ │ ├── configuration.rb │ │ ├── dsl.rb │ │ ├── field_processor.rb │ │ ├── initializer.rb │ │ ├── instances │ │ │ ├── active_record.rb │ │ │ ├── base.rb │ │ │ └── salesforce.rb │ │ ├── loggable.rb │ │ ├── mapping.rb │ │ ├── middleware │ │ │ └── store_request_body.rb │ │ ├── model.rb │ │ ├── railtie.rb │ │ ├── record_cache.rb │ │ ├── record_types │ │ │ ├── active_record.rb │ │ │ ├── base.rb │ │ │ └── salesforce.rb │ │ ├── registry.rb │ │ ├── runner.rb │ │ ├── strategies │ │ │ ├── always.rb │ │ │ ├── associated.rb │ │ │ └── passive.rb │ │ ├── strategy.rb │ │ ├── synchronization_error.rb │ │ ├── synchronizer.rb │ │ ├── task.rb │ │ ├── task_manager.rb │ │ ├── timestamp_cache.rb │ │ ├── tracker.rb │ │ ├── version.rb │ │ └── worker.rb │ └── extensions.rb └── tasks │ └── restforce.rake ├── restforce-db.gemspec └── test ├── cassettes ├── Restforce_DB │ └── accessing_Salesforce │ │ └── uses_the_configured_credentials.yml ├── Restforce_DB_Associations_BelongsTo │ └── with_an_inverse_mapping │ │ ├── _build │ │ ├── returns_an_associated_record_populated_with_the_Salesforce_attributes.yml │ │ ├── when_no_salesforce_record_is_found_for_the_association │ │ │ └── proceeds_without_constructing_any_records.yml │ │ ├── when_the_associated_record_has_already_been_persisted │ │ │ └── assigns_the_existing_record.yml │ │ ├── when_the_associated_record_has_been_cached │ │ │ └── uses_the_cached_record.yml │ │ ├── when_the_association_is_non-building │ │ │ └── proceeds_without_constructing_any_records.yml │ │ └── with_an_unrelated_association_mapping │ │ │ └── proceeds_without_raising_an_error.yml │ │ ├── _lookups │ │ ├── returns_a_hash_of_the_associated_records_lookup_IDs.yml │ │ └── when_there_is_currently_no_associated_record │ │ │ ├── and_the_underlying_association_is_one-to-many │ │ │ └── still_returns_no_value_in_the_hash.yml │ │ │ └── returns_no_value_in_the_hash.yml │ │ └── _synced_for_ │ │ ├── when_a_matching_associated_record_has_been_synchronized │ │ └── returns_true.yml │ │ └── when_no_matching_associated_record_has_been_synchronized │ │ └── returns_false.yml ├── Restforce_DB_Associations_HasMany │ └── with_an_inverse_mapping │ │ ├── _build │ │ ├── builds_a_number_of_associated_records_from_the_data_in_Salesforce.yml │ │ ├── when_no_salesforce_record_is_found_for_the_association │ │ │ └── proceeds_without_constructing_any_records.yml │ │ ├── when_the_associated_records_have_alrady_been_persisted │ │ │ └── constructs_the_association_from_the_existing_records.yml │ │ ├── when_the_associated_records_have_been_cached │ │ │ └── uses_the_cached_records.yml │ │ └── when_the_association_is_non-building │ │ │ └── proceeds_without_constructing_any_records.yml │ │ └── _synced_for_ │ │ ├── when_a_matching_associated_record_has_been_synchronized │ │ └── returns_true.yml │ │ └── when_no_matching_associated_record_has_been_synchronized │ │ └── returns_false.yml ├── Restforce_DB_Associations_HasOne │ └── with_an_inverse_mapping │ │ ├── _build │ │ ├── and_a_nested_association_on_the_associated_mapping │ │ │ └── recursively_builds_all_associations.yml │ │ ├── returns_an_associated_record_populated_with_the_Salesforce_attributes.yml │ │ ├── when_multiple_mutually-exclusive_mappings_exist │ │ │ └── associates_through_the_proper_mapping.yml │ │ ├── when_no_salesforce_record_is_found_for_the_association │ │ │ └── proceeds_without_constructing_any_records.yml │ │ ├── when_the_associated_record_has_already_been_persisted │ │ │ └── assigns_the_existing_record.yml │ │ ├── when_the_associated_record_has_been_cached │ │ │ └── uses_the_cached_record.yml │ │ └── when_the_association_is_non-building │ │ │ └── proceeds_without_constructing_any_records.yml │ │ └── _synced_for_ │ │ ├── when_a_matching_associated_record_has_been_synchronized │ │ └── returns_true.yml │ │ └── when_no_matching_associated_record_has_been_synchronized │ │ └── returns_false.yml ├── 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 │ │ └── when_the_database_association_is_out_of_date │ │ └── updates_the_associated_record_in_the_database.yml ├── Restforce_DB_Attacher │ └── _run │ │ └── given_a_Salesforce_record_with_an_upsert_ID │ │ ├── for_a_Passive_strategy │ │ └── does_nothing.yml │ │ └── for_an_Always_strategy │ │ ├── links_the_Salesforce_record_to_the_matching_database_record.yml │ │ ├── when_no_matching_database_record_can_be_found │ │ └── wipes_the_SynchronizationId__c.yml │ │ ├── when_the_matching_database_record_has_a_salesforce_id │ │ ├── does_not_change_the_current_Salesforce_ID.yml │ │ └── wipes_the_SynchronizationId__c.yml │ │ ├── when_the_upsert_ID_is_for_another_database_model │ │ └── does_not_wipe_the_SynchronizationId__c.yml │ │ └── wipes_the_SynchronizationId__c.yml ├── Restforce_DB_Cleaner │ └── _run │ │ └── given_a_synchronized_Salesforce_record │ │ ├── when_the_mapping_has_no_conditions │ │ └── does_not_drop_the_synchronized_database_record.yml │ │ ├── when_the_record_does_not_meet_the_mapping_conditions │ │ ├── but_meets_conditions_for_a_parallel_mapping │ │ │ └── does_not_drop_the_synchronized_database_record.yml │ │ └── drops_the_synchronized_database_record.yml │ │ ├── when_the_record_has_been_deleted_in_Salesforce │ │ └── drops_the_synchronized_database_record.yml │ │ └── when_the_record_meets_the_mapping_conditions │ │ └── does_not_drop_the_synchronized_database_record.yml ├── Restforce_DB_Collector │ └── _run │ │ ├── given_a_Salesforce_record_with_an_associated_database_record │ │ └── returns_the_attributes_from_both_records.yml │ │ ├── given_an_existing_Salesforce_record │ │ ├── which_has_been_synchronized │ │ │ └── returns_the_attributes_from_the_Salesforce_record.yml │ │ └── which_has_not_been_synchronized │ │ │ └── does_not_store_any_attributes.yml │ │ ├── given_an_existing_database_record │ │ └── returns_the_attributes_from_the_database_record.yml │ │ └── when_the_record_has_not_been_updated_outside_of_the_system │ │ └── does_not_collect_any_changes.yml ├── Restforce_DB_Initializer │ └── _run │ │ ├── given_an_existing_Salesforce_record │ │ ├── for_a_Passive_strategy │ │ │ └── does_not_create_a_database_record.yml │ │ └── for_an_Always_strategy │ │ │ └── creates_a_matching_database_record.yml │ │ └── given_an_existing_database_record │ │ └── for_an_Always_strategy │ │ └── populates_Salesforce_with_the_new_record.yml ├── Restforce_DB_Instances_Salesforce │ ├── _synced_ │ │ ├── when_a_matching_database_record_exists │ │ │ └── returns_true.yml │ │ └── when_no_matching_database_record_exists │ │ │ └── returns_false.yml │ ├── _update_ │ │ ├── updates_the_local_record_with_the_passed_attributes.yml │ │ └── updates_the_record_in_Salesforce_with_the_passed_attributes.yml │ └── _updated_internally_ │ │ ├── when_another_user_made_the_last_change │ │ └── returns_false.yml │ │ └── when_our_client_made_the_last_change │ │ └── returns_true.yml ├── Restforce_DB_Model │ └── given_a_database_model_which_includes_the_module │ │ └── _force_sync_ │ │ ├── given_a_previously-synchronized_record_for_a_mapped_model │ │ ├── and_a_mutually_exclusive_mapping │ │ │ └── ignores_the_problematic_mapping.yml │ │ └── force-updates_both_synchronized_records.yml │ │ └── given_an_unsynchronized_record_for_a_mapped_model │ │ └── creates_a_matching_record_in_Salesforce.yml ├── Restforce_DB_RecordTypes_Salesforce │ ├── _all │ │ └── returns_a_list_of_the_existing_records_in_Salesforce.yml │ ├── _create_ │ │ ├── creates_a_record_in_Salesforce_from_the_passed_database_record_s_attributes.yml │ │ ├── updates_the_database_record_with_the_Salesforce_record_s_ID.yml │ │ ├── when_a_Salesforce_record_already_exists_for_the_database_instance │ │ │ └── uses_the_existing_record.yml │ │ └── wipes_the_temporary_SynchronizationId__c_value_used_for_upsert.yml │ └── _find │ │ ├── finds_existing_records_in_Salesforce.yml │ │ ├── given_a_set_of_mapping_conditions │ │ ├── when_a_record_does_not_meet_the_conditions │ │ │ └── does_not_find_the_record.yml │ │ └── when_a_record_meets_the_conditions │ │ │ └── finds_the_record.yml │ │ └── returns_nil_when_no_matching_record_exists.yml ├── Restforce_DB_Strategies_Always │ └── _build_ │ │ └── given_a_Salesforce_record │ │ ├── wants_to_build_a_new_matching_record.yml │ │ └── with_a_corresponding_database_record │ │ └── does_not_want_to_build_a_new_record.yml ├── Restforce_DB_Strategies_Associated │ └── _build_ │ │ └── given_an_inverse_mapping │ │ ├── with_a_synchronized_association_record │ │ └── wants_to_build_a_new_record.yml │ │ ├── with_an_existing_database_record │ │ └── does_not_want_to_build_a_new_record.yml │ │ └── with_no_synchronized_association_record │ │ └── does_not_want_to_build_a_new_record.yml ├── 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 │ │ │ └── does_not_update_the_salesforce_record.yml │ │ └── when_the_changes_are_current │ │ │ ├── updates_the_database_record.yml │ │ │ └── updates_the_salesforce_record.yml │ │ └── given_a_Salesforce_record_with_no_associated_database_record │ │ └── does_nothing_for_this_specific_mapping.yml └── Restforce_DB_Worker │ └── a_race_condition_during_synchronization │ ├── does_not_change_the_user-entered_name_on_the_database_record.yml │ └── overrides_the_stale-but-more-recent_name_on_the_Salesforce.yml ├── lib ├── forked_process_test.rb └── restforce │ ├── db │ ├── accumulator_test.rb │ ├── adapter_test.rb │ ├── association_cache_test.rb │ ├── associations │ │ ├── belongs_to_test.rb │ │ ├── has_many_test.rb │ │ └── has_one_test.rb │ ├── associator_test.rb │ ├── attacher_test.rb │ ├── attribute_map_test.rb │ ├── attribute_maps │ │ ├── database_test.rb │ │ └── salesforce_test.rb │ ├── cleaner_test.rb │ ├── collector_test.rb │ ├── configuration_test.rb │ ├── dsl_test.rb │ ├── field_processor_test.rb │ ├── initializer_test.rb │ ├── instances │ │ ├── active_record_test.rb │ │ └── salesforce_test.rb │ ├── mapping_test.rb │ ├── model_test.rb │ ├── record_cache_test.rb │ ├── record_types │ │ ├── active_record_test.rb │ │ └── salesforce_test.rb │ ├── registry_test.rb │ ├── runner_test.rb │ ├── strategies │ │ ├── always_test.rb │ │ ├── associated_test.rb │ │ └── passive_test.rb │ ├── strategy_test.rb │ ├── synchronizer_test.rb │ ├── timestamp_cache_test.rb │ ├── tracker_test.rb │ └── worker_test.rb │ └── db_test.rb ├── support ├── active_record.rb ├── database_cleaner.rb ├── matchers.rb ├── salesforce.rb ├── stub.rb ├── utilities.rb └── vcr.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.ruby-gemset 3 | /.ruby-version 4 | /.yardoc 5 | /Gemfile.lock 6 | /_yardoc/ 7 | /coverage/ 8 | /doc/ 9 | /pkg/ 10 | /spec/reports/ 11 | /tmp/ 12 | /test/config/* 13 | 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - ./.rubocop/custom/method_documentation.rb 3 | 4 | AllCops: 5 | Exclude: 6 | - ".bundle/**/*" 7 | - "vendor/**/*" 8 | 9 | Metrics/AbcSize: 10 | Max: 25 11 | 12 | Metrics/LineLength: 13 | Enabled: false 14 | 15 | Metrics/MethodLength: 16 | Max: 20 17 | 18 | Style/EmptyLinesAroundBlockBody: 19 | Exclude: 20 | # These are naturally DSL-y, and so let's be lenient 21 | - "test/**/*" 22 | 23 | Style/EmptyLinesAroundClassBody: 24 | EnforcedStyle: empty_lines 25 | 26 | Style/EmptyLinesAroundModuleBody: 27 | EnforcedStyle: empty_lines 28 | 29 | Style/SignalException: 30 | EnforcedStyle: only_raise 31 | 32 | Style/StringLiterals: 33 | EnforcedStyle: double_quotes 34 | 35 | Style/TrailingComma: 36 | EnforcedStyleForMultiline: comma 37 | 38 | Style/TrivialAccessors: 39 | ExactNameMatch: true 40 | -------------------------------------------------------------------------------- /.rubocop/custom/method_documentation.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module RuboCop 4 | 5 | module Cop 6 | 7 | module Style 8 | 9 | # This cop checks for missing method-level documentation. 10 | class MethodDocumentation < Cop 11 | include AnnotationComment 12 | 13 | MSG = "Missing method-level documentation comment." 14 | 15 | # Public: Investigate the source for undocumented methods. 16 | def investigate(processed_source) 17 | ast = processed_source.ast 18 | return unless ast 19 | 20 | ast_with_comments = Parser::Source::Comment.associate( 21 | ast, 22 | processed_source.comments 23 | ) 24 | 25 | check(ast, ast_with_comments) 26 | end 27 | 28 | private 29 | 30 | # Internal: Ensure that documentation is required for all methods. Adds 31 | # an offense when undocumented methods are detected. 32 | # 33 | # Returns nothing. 34 | def check(ast, ast_with_comments) 35 | ast.each_node(:def) do |node| 36 | _name, body = *node 37 | 38 | next if associated_comment?(node, ast_with_comments) 39 | add_offense(node, :keyword, MSG) 40 | end 41 | end 42 | 43 | # Internal: Does the node have a non-annotation comment on the preceding 44 | # line? 45 | # 46 | # Returns a Boolean. 47 | def associated_comment?(node, ast_with_comments) 48 | return false if ast_with_comments[node].empty? 49 | 50 | preceding_comment = ast_with_comments[node].last 51 | distance = node.loc.keyword.line - preceding_comment.loc.line 52 | return false if distance > 1 53 | 54 | # As long as there's at least one comment line that isn't an 55 | # annotation, it's OK. 56 | ast_with_comments[node].any? { |comment| !annotation?(comment) } 57 | end 58 | 59 | end 60 | 61 | end 62 | 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in restforce-db.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Table XI Partners, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "rubocop/rake_task" 6 | 7 | Rake::TestTask.new do |t| 8 | t.pattern = "test/**/*_test.rb" 9 | end 10 | 11 | RuboCop::RakeTask.new 12 | 13 | task default: [:rubocop, :test] 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "restforce/db" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | checkout: 2 | post: 3 | - mkdir -p test/config 4 | - cp lib/generators/templates/config.yml test/config/secrets.yml 5 | machine: 6 | ruby: 7 | version: 2.2.2 8 | dependencies: 9 | pre: 10 | # Get the most recent bundler 11 | - gem install bundler 12 | database: 13 | override: 14 | # Do nothing; we don't need any database setup. 15 | - echo "SKIPPING" 16 | test: 17 | override: 18 | - bundle exec rubocop 19 | - bundle exec rake test 20 | -------------------------------------------------------------------------------- /lib/file_daemon.rb: -------------------------------------------------------------------------------- 1 | # FileDaemon defines some standard hooks for forking processes which retain 2 | # file descriptors. Implementation derived from the Delayed::Job library: 3 | # https://github.com/collectiveidea/delayed_job/blob/master/lib/delayed/worker.rb#L77-L98. 4 | module FileDaemon 5 | 6 | # Public: Extend the including class with before/after_fork hooks. 7 | # 8 | # base - The including class. 9 | # 10 | # Returns nothing. 11 | def self.included(base) 12 | base.extend(ClassMethods) 13 | end 14 | 15 | # :nodoc: 16 | module ClassMethods 17 | 18 | # Public: Force-reopen all files at their current paths. Allows for rotation 19 | # of log files outside of the context of an actual process fork. 20 | # 21 | # Returns nothing. 22 | def reopen_files 23 | before_fork 24 | after_fork 25 | end 26 | 27 | # Public: Store the list of currently open file descriptors so that they 28 | # may be reopened when a new process is spawned. 29 | # 30 | # Returns nothing. 31 | def before_fork 32 | @files_to_reopen = ObjectSpace.each_object(File).reject(&:closed?) 33 | end 34 | 35 | # Public: Reopen all file descriptors that have been stored through the 36 | # before_fork hook. 37 | # 38 | # Returns nothing. 39 | def after_fork 40 | @files_to_reopen.each do |file| 41 | begin 42 | file.reopen file.path, "a+" 43 | file.sync = true 44 | rescue ::IOError # rubocop:disable HandleExceptions 45 | end 46 | end 47 | end 48 | 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/forked_process.rb: -------------------------------------------------------------------------------- 1 | require "English" 2 | 3 | # ForkedProcess exposes a small API for performing a block of code in a 4 | # forked process, and relaying its output to another block. 5 | class ForkedProcess 6 | 7 | class UnsuccessfulExit < RuntimeError; end 8 | 9 | # Public: Define a callback which will be run in a forked process. 10 | # 11 | # Yields an IO object opened for writing when `run` is invoked. 12 | # Returns nothing. 13 | def write(&block) 14 | @write_block = block 15 | end 16 | 17 | # Public: Define a callback which reads in the output from the forked 18 | # process. 19 | # 20 | # Yields an IO object opened for reading when `run` is invoked. 21 | # Returns nothing. 22 | def read(&block) 23 | @read_block = block 24 | end 25 | 26 | # Public: Fork a process, opening a pipe for IO and yielding the write and 27 | # read components to the relevant blocks. 28 | # 29 | # Returns nothing. 30 | def run 31 | reader, writer = IO.pipe 32 | 33 | pid = fork do 34 | reader.close 35 | @write_block.call(writer) 36 | writer.close 37 | exit!(0) 38 | end 39 | 40 | writer.close 41 | @read_block.call(reader) 42 | Process.wait(pid) 43 | 44 | raise UnsuccessfulExit unless $CHILD_STATUS.success? 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /lib/generators/restforce/install_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/base" 2 | 3 | module Restforce 4 | 5 | # :nodoc: 6 | class InstallGenerator < Rails::Generators::Base 7 | 8 | source_root File.expand_path("../../templates", __FILE__) 9 | 10 | # :nodoc: 11 | def create_config_file 12 | template "config.yml", "config/restforce-db.yml" 13 | end 14 | 15 | # :nodoc: 16 | def create_executable_file 17 | template "script", "bin/restforce-db" 18 | chmod "bin/restforce-db", 0755 19 | end 20 | 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/generators/restforce/migration_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/active_record" 2 | 3 | module Restforce 4 | 5 | # :nodoc: 6 | class MigrationGenerator < ActiveRecord::Generators::Base 7 | 8 | source_root File.expand_path("../../templates", __FILE__) 9 | 10 | # :nodoc: 11 | def create_migration_file 12 | migration_template "migration.rb", "db/migrate/add_#{singular_name}_salesforce_binding.rb" 13 | end 14 | 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/generators/templates/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | client: 3 | username: "" 4 | password: "" 5 | security_token: "" 6 | client_id: "" 7 | client_secret: "" 8 | host: "login.salesforce.com" 9 | 10 | # The following are not required, but you may want to modify them: 11 | api_version: "29.0" 12 | timeout: 5 13 | adapter: "net_http" 14 | -------------------------------------------------------------------------------- /lib/generators/templates/migration.rb.tt: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | class Add<%= class_name %>SalesforceBinding < ActiveRecord::Migration 3 | 4 | # :nodoc: 5 | def change 6 | add_column :<%= table_name %>, :salesforce_id, :string 7 | add_column :<%= table_name %>, :synchronized_at, :datetime 8 | 9 | add_index :<%= table_name %>, :salesforce_id, unique: true 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /lib/generators/templates/script: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | if ARGV[0] != "meta" 4 | require File.expand_path(File.join(File.dirname(__FILE__), "..", "config", "environment")) 5 | require "restforce/db/command" 6 | 7 | Restforce::DB::Command.new(ARGV).daemonize 8 | elsif ARGV.length > 1 && !defined?(Bundler) 9 | 10 | # MetadataInitializer exposes functionality to automatically add an 11 | # external Synchronization ID to your Salesforce models. 12 | class MetadataInitializer 13 | 14 | # Public: Initialize a new MetadataInitializer. 15 | # 16 | # config_file - The path to a configuration file. 17 | # models - An Array of String Salesforce model names. 18 | def initialize(config_file, *models) 19 | @models = models 20 | @config = YAML.load_file(config_file) 21 | end 22 | 23 | # Public: Set up an external Synchronization ID for each specified 24 | # Salesforce model. 25 | # 26 | # Returns nothing. 27 | def setup 28 | @models.each do |model| 29 | print "ADDING SynchronizationId__c to #{model}... " 30 | job = client.create( 31 | :custom_field, 32 | full_name: "#{model}.SynchronizationId__c", 33 | description: "External Synchronization ID", 34 | label: "Synchronization ID", 35 | type: "Text", 36 | external_id: true, 37 | unique: true, 38 | length: ENV["UUID_LENGTH"] || 255, 39 | ) 40 | 41 | begin 42 | job.on_complete { |_| puts "DONE" }.on_error { |_| puts "FAILED" }.perform 43 | rescue Savon::SOAP::Fault => e 44 | puts "\n(#{e.class}) #{e.message}\n#{e.backtrace.join("\n")}" 45 | end 46 | end 47 | end 48 | 49 | # Internal: Get a Metaforce client to process the custom field CRUD 50 | # requests. 51 | # 52 | # Returns a Metaforce::Metadata:Client. 53 | def client 54 | @client ||= begin 55 | Metaforce.configure do |configuration| 56 | configuration.host = @config["client"]["host"] 57 | configuration.log = false 58 | end 59 | 60 | options = { 61 | username: @config["client"]["username"], 62 | password: @config["client"]["password"], 63 | security_token: @config["client"]["security_token"], 64 | } 65 | 66 | # Verify the login credentials 67 | Metaforce.login(options) 68 | Metaforce.new(options) 69 | end 70 | end 71 | 72 | end 73 | 74 | # NOTE: For whatever reason, bundler/inline's "install" functionality seems 75 | # busted. We can work around it by just installing the gem ourselves, as a 76 | # convenience to users. 77 | unless `gem list metaforce -i` == "true\n" 78 | system "gem install metaforce" 79 | puts 80 | end 81 | 82 | # Suppress bundler output 83 | require "stringio" 84 | stdout, $stdout = $stdout, StringIO.new 85 | 86 | require "bundler/inline" 87 | require "yaml" 88 | 89 | gemfile(true) do 90 | source "https://rubygems.org" 91 | 92 | # NOTE: Necessary to deal with a bad require in Metaforce 93 | gem "activesupport", require: "active_support" 94 | gem "metaforce" 95 | end 96 | 97 | $stdout = stdout 98 | 99 | config_file = ENV["CONFIG"] || Pathname.new(Dir.pwd).join("config", "restforce-db.yml") 100 | MetadataInitializer.new(config_file, *ARGV[1..-1]).setup 101 | else 102 | puts "The `meta` command doesn't work through `bundle exec`." if defined?(Bundler) 103 | puts "You must supply at least one Salesforce model." if ARGV.length < 2 104 | puts "Use the syntax `ruby bin/restforce-db meta [...]`" 105 | end 106 | -------------------------------------------------------------------------------- /lib/restforce/db/accumulator.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Accumulator is responsible for the accumulation of changes 6 | # over the course of a single synchronization run. As we iterate over the 7 | # various mappings, we build a set of changes for each Salesforce ID, which 8 | # is then applied to all objects synchronized with that Salesforce object. 9 | class Accumulator < Hash 10 | 11 | # Public: Store the changeset under the given timestamp. If a changeset 12 | # for that timestamp has already been registered, merge it with the newly 13 | # passed changeset. 14 | # 15 | # timestamp - A Time object. 16 | # changeset - A Hash mapping attribute names to values. 17 | # 18 | # Returns nothing. 19 | def store(timestamp, changeset) 20 | return super unless key?(timestamp) 21 | self[timestamp].merge!(changeset) 22 | end 23 | 24 | # Public: Get the accumulated list of attributes after all changes have 25 | # been applied. 26 | # 27 | # Returns a Hash. 28 | def attributes 29 | @attributes ||= sort.reverse.inject({}) do |final, (_, changeset)| 30 | changeset.merge(final) 31 | end 32 | end 33 | 34 | # Public: Get a Hash representing the current values for the items in the 35 | # passed Hash, as a subset of this Accumulator's attributes Hash. 36 | # 37 | # comparison - A Hash mapping of attributes to values. 38 | # 39 | # Returns a Hash. 40 | def current(comparison) 41 | attributes.each_with_object({}) do |(attribute, value), final| 42 | next unless comparison.key?(attribute) 43 | final[attribute] = value 44 | end 45 | end 46 | 47 | # Public: Do the canonical attributes stored in this Accumulator differ 48 | # from those in the passed comparison Hash? 49 | # 50 | # comparison - A Hash mapping of attributes to values. 51 | # 52 | # Returns a Boolean. 53 | def changed?(comparison) 54 | attributes.any? do |attribute, value| 55 | next unless comparison.key?(attribute) 56 | comparison[attribute] != value 57 | end 58 | end 59 | 60 | # Public: Does the timestamp of the most recent change meet or exceed the 61 | # specified timestamp? 62 | # 63 | # timestamp - A Time object. 64 | # 65 | # Returns a Boolean. 66 | def up_to_date_for?(timestamp) 67 | keys.sort.last >= timestamp 68 | end 69 | 70 | end 71 | 72 | end 73 | 74 | end 75 | -------------------------------------------------------------------------------- /lib/restforce/db/adapter.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Adapter defines the default data conversions between 6 | # database and Salesforce formats. It translates Dates and Times to ISO-8601 7 | # format for storage in Salesforce. 8 | class Adapter 9 | 10 | # Public: Convert the passed attribute hash to a format consumable by 11 | # the ActiveRecord model. By default, performs no conversions. 12 | # 13 | # attributes - A Hash of attributes, with keys corresponding to a Mapping. 14 | # 15 | # Returns a Hash. 16 | def to_database(attributes) 17 | attributes.dup 18 | end 19 | 20 | # Public: Convert the passed attribute hash to a format consumable by 21 | # Salesforce. 22 | # 23 | # attributes - A Hash of attributes, with keys corresponding to a Mapping. 24 | # 25 | # Returns a Hash. 26 | def from_database(attributes) 27 | attributes.each_with_object({}) do |(key, value), final| 28 | value = value.utc if value.respond_to?(:utc) 29 | value = value.iso8601 if value.respond_to?(:iso8601) 30 | 31 | final[key] = value 32 | end 33 | end 34 | 35 | end 36 | 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /lib/restforce/db/association_cache.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::AssociationCache stores a set of constructed database 6 | # association records, providing utilities to fetch unpersisted records 7 | # which match a specific set of Salesforce lookups. 8 | class AssociationCache 9 | 10 | attr_reader :cache 11 | 12 | # Public: Initialize a new Restforce::DB::AssociationCache. 13 | # 14 | # record - An instance of ActiveRecord::Base (optional). 15 | def initialize(record = nil) 16 | @cache = Hash.new { |h, k| h[k] = [] } 17 | self << record if record 18 | end 19 | 20 | # Public: Add a record to the cache. 21 | # 22 | # record - An instance of ActiveRecord::Base. 23 | # 24 | # Returns nothing. 25 | def <<(record) 26 | @cache[record.class] << record 27 | end 28 | 29 | # Public: Find an existing record with the given lookup values. 30 | # 31 | # database_model - A subclass of ActiveRecord::Base. 32 | # lookups - A Hash mapping database columns to Salesforce IDs. 33 | # 34 | # Returns an instance of ActiveRecord::Base or nil. 35 | def find(database_model, lookups) 36 | record = @cache[database_model].detect do |cached| 37 | lookups.all? { |column, value| cached.send(column) == value } 38 | end 39 | 40 | return record if record 41 | 42 | record = database_model.find_by(lookups) 43 | self << record if record 44 | 45 | record 46 | end 47 | 48 | end 49 | 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/restforce/db/associations/foreign_key.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Associations 6 | 7 | # Restforce::DB::Associations::ForeignKey defines a relationship in which 8 | # the Salesforce IDs for any associated record(s) are present on a foreign 9 | # record type. 10 | class ForeignKey < Base 11 | 12 | # Public: Get a list of fields which should be included in the 13 | # Salesforce record's lookups for any mapping including this 14 | # association. 15 | # 16 | # Returns a list of Salesforce fields this record should return. 17 | def fields 18 | [] 19 | end 20 | 21 | private 22 | 23 | # Internal: Identify the inverse mapping for this relationship by 24 | # looking it up through the target association. 25 | # 26 | # database_record - An instance of an ActiveRecord::Base subclass. 27 | # 28 | # Returns a Restforce::DB::Mapping. 29 | def target_mappings(database_record) 30 | inverse = inverse_association_name(target_reflection(database_record)) 31 | Registry[target_class(database_record)].select do |mapping| 32 | mapping.associations.any? { |a| a.name == inverse } 33 | end 34 | end 35 | 36 | # Internal: Construct a single associated record from the supplied 37 | # Salesforce instance. 38 | # 39 | # database_record - An instance of an ActiveRecord::Base subclass. 40 | # salesforce_instance - A Restforce::DB::Instances::Salesforce. 41 | # 42 | # Returns an Array of constructed associated objects. 43 | def construct_for(database_record, salesforce_instance) 44 | mapping = salesforce_instance.mapping 45 | lookups = { mapping.lookup_column => salesforce_instance.id } 46 | 47 | attributes = mapping.convert( 48 | mapping.database_model, 49 | salesforce_instance.attributes, 50 | ) 51 | 52 | constructed_records(database_record, lookups, attributes) do |associated| 53 | nested_records(database_record, associated, salesforce_instance) 54 | end 55 | end 56 | 57 | # Internal: Get the Salesforce ID belonging to the associated record 58 | # for a supplied instance. Must be implemented per-association. 59 | # 60 | # instance - A Restforce::DB::Instances::Base 61 | # 62 | # Returns a String. 63 | def associated_salesforce_id(instance) 64 | reflection = instance.mapping.database_model.reflect_on_association(name) 65 | 66 | mappings_for(reflection).detect do |inverse_mapping| 67 | query = "#{lookup_field(inverse_mapping, reflection)} = '#{instance.id}'" 68 | salesforce_instance = inverse_mapping.salesforce_record_type.first(query) 69 | break salesforce_instance.id if salesforce_instance 70 | end 71 | end 72 | 73 | end 74 | 75 | end 76 | 77 | end 78 | 79 | end 80 | -------------------------------------------------------------------------------- /lib/restforce/db/associations/has_many.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Associations 6 | 7 | # Restforce::DB::Associations::HasMany defines a relationship in which 8 | # potentially several Salesforce records maintain a reference to the 9 | # Salesforce record on the current Mapping. 10 | class HasMany < ForeignKey 11 | 12 | # Public: Construct a database record for each Salesforce record 13 | # associated with the supplied parent Salesforce record. 14 | # 15 | # database_record - An instance of an ActiveRecord::Base subclass. 16 | # salesforce_record - A Hashie::Mash representing a Salesforce object. 17 | # cache - A Restforce::DB::AssociationCache (optional). 18 | # 19 | # Returns an Array of constructed association records. 20 | def build(database_record, salesforce_record, cache = AssociationCache.new(database_record)) 21 | return [] unless build? 22 | 23 | @cache = cache 24 | 25 | reflection = target_reflection(database_record) 26 | records = [] 27 | 28 | target_mappings(database_record).each do |target| 29 | lookup_id = "#{lookup_field(target, reflection)} = '#{salesforce_record.Id}'" 30 | 31 | target.salesforce_record_type.all(conditions: lookup_id).each do |instance| 32 | records << construct_for(database_record, instance) 33 | end 34 | end 35 | 36 | records.flatten 37 | ensure 38 | @cache = nil 39 | end 40 | 41 | private 42 | 43 | # Internal: Get the method by which an associated record should be 44 | # assigned to this record. Replaces :writer with :concat, which appends 45 | # records to an existing association, rather than replacing it. 46 | # 47 | # Returns a Symbol. 48 | def construction_method 49 | :concat 50 | end 51 | 52 | end 53 | 54 | end 55 | 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /lib/restforce/db/associations/has_one.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Associations 6 | 7 | # Restforce::DB::Associations::HasOne defines a relationship in which a 8 | # Salesforce ID for this Mapping's database record exists on the named 9 | # database association's Mapping. 10 | class HasOne < ForeignKey 11 | 12 | # Public: Construct a database record from a single Salesforce record 13 | # associated with the supplied parent Salesforce record. 14 | # 15 | # database_record - An instance of an ActiveRecord::Base subclass. 16 | # salesforce_record - A Hashie::Mash representing a Salesforce object. 17 | # cache - A Restforce::DB::AssociationCache (optional). 18 | # 19 | # Returns an Array of constructed association records. 20 | def build(database_record, salesforce_record, cache = AssociationCache.new(database_record)) 21 | return [] unless build? 22 | 23 | @cache = cache 24 | 25 | targets = target_mappings(database_record) 26 | reflection = target_reflection(database_record) 27 | 28 | records = targets.detect do |target| 29 | query = "#{lookup_field(target, reflection)} = '#{salesforce_record.Id}'" 30 | instance = target.salesforce_record_type.first(query) 31 | 32 | break construct_for(database_record, instance) if instance 33 | end 34 | 35 | records || [] 36 | ensure 37 | @cache = nil 38 | end 39 | 40 | end 41 | 42 | end 43 | 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /lib/restforce/db/attacher.rb: -------------------------------------------------------------------------------- 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 "::" 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 92 | -------------------------------------------------------------------------------- /lib/restforce/db/attribute_map.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::AttributeMap encapsulates the logic for converting between 6 | # various representations of attribute hashes. 7 | # 8 | # For the purposes of our mappings, a "normalized" attribute Hash maps the 9 | # Salesforce field names to Salesforce-compatible values. Value conversion 10 | # into and out of the database occurs through a lightweight Adapter object. 11 | class AttributeMap 12 | 13 | # Public: Initialize a Restforce::DB::AttributeMap. 14 | # 15 | # database_model - A Class compatible with ActiveRecord::Base. 16 | # salesforce_model - A String name of an object type in Salesforce. 17 | # fields - A Hash of mappings between database columns and 18 | # fields in Salesforce. 19 | # adapter - An adapter object which should be used to convert 20 | # between data formats. 21 | def initialize(database_model, salesforce_model, fields = {}, adapter = Adapter.new) 22 | @field_maps = { 23 | database_model => AttributeMaps::Database.new(fields, adapter), 24 | salesforce_model => AttributeMaps::Salesforce.new(fields), 25 | } 26 | end 27 | 28 | # Public: Build a normalized Hash of attributes from the appropriate set 29 | # of mappings. The keys of the resulting mapping Hash will correspond to 30 | # the Salesforce field names. 31 | # 32 | # from_format - A String or Class reflecting the record type from which 33 | # the attribute Hash is being compiled. 34 | # record - The underlying record for which attributes should be 35 | # collected. 36 | # 37 | # Returns a Hash. 38 | def attributes(from_format, record) 39 | @field_maps[from_format].attributes(record) 40 | end 41 | 42 | # Public: Convert a Hash of normalized attributes to a format compatible 43 | # with a specific platform. 44 | # 45 | # to_format - A String or Class reflecting the record type for which the 46 | # attribute Hash is being compiled. 47 | # attributes - A Hash of attributes, with keys corresponding to the 48 | # normalized attribute names. 49 | # 50 | # Examples 51 | # 52 | # mapping = AttributeMap.new( 53 | # MyClass, 54 | # "Object__c", 55 | # some_key: "SomeField__c", 56 | # ) 57 | # 58 | # mapping.convert(MyClass, "Some_Field__c" => "some value") 59 | # # => { some_key: "some value" } 60 | # 61 | # mapping.convert("Object__c", "Some_Field__c" => "some other value") 62 | # # => { "Some_Field__c" => "some other value" } 63 | # 64 | # Returns a Hash. 65 | def convert(to_format, attributes) 66 | @field_maps[to_format].convert(attributes) 67 | end 68 | 69 | end 70 | 71 | end 72 | 73 | end 74 | -------------------------------------------------------------------------------- /lib/restforce/db/attribute_maps/database.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module AttributeMaps 6 | 7 | # Restforce::DB::AttributeMaps::Database encapsulates the logic for 8 | # compiling and parsing normalized attribute hashes from/for ActiveRecord 9 | # objects. 10 | class Database 11 | 12 | # Public: Initialize a Restforce::DB::AttributeMaps::Database. 13 | # 14 | # fields - A Hash of mappings between database columns and fields in 15 | # Salesforce. 16 | # adapter - An adapter object which should be used to convert between 17 | # data formats. 18 | def initialize(fields, adapter = Adapter.new) 19 | @fields = fields 20 | @adapter = adapter 21 | end 22 | 23 | # Public: Build a normalized Hash of attributes from the appropriate set 24 | # of mappings. The keys of the resulting mapping Hash will correspond to 25 | # the Salesforce field names. 26 | # 27 | # record - The underlying ActiveRecord object for which attributes 28 | # should be collected. 29 | # 30 | # Returns a Hash. 31 | def attributes(record) 32 | attributes = @fields.keys.each_with_object({}) do |attribute, values| 33 | values[attribute] = record.send(attribute) 34 | end 35 | attributes = @adapter.from_database(attributes) 36 | 37 | @fields.each_with_object({}) do |(attribute, mapping), final| 38 | final[mapping] = attributes[attribute] 39 | end 40 | end 41 | 42 | # Public: Convert a Hash of normalized attributes to a format suitable 43 | # for consumption by an ActiveRecord object. 44 | # 45 | # attributes - A Hash of attributes, with keys corresponding to the 46 | # normalized Salesforce attribute names. 47 | # 48 | # Examples 49 | # 50 | # attribute_map = AttributeMaps::Database.new( 51 | # some_key: "SomeField__c", 52 | # ) 53 | # 54 | # attribute_map.convert(MyClass, "Some_Field__c" => "some value") 55 | # # => { some_key: "some value" } 56 | # 57 | # Returns a Hash. 58 | def convert(attributes) 59 | attributes = @fields.each_with_object({}) do |(attribute, mapping), converted| 60 | next unless attributes.key?(mapping) 61 | converted[attribute] = attributes[mapping] 62 | end 63 | 64 | @adapter.to_database(attributes) 65 | end 66 | 67 | end 68 | 69 | end 70 | 71 | end 72 | 73 | end 74 | -------------------------------------------------------------------------------- /lib/restforce/db/attribute_maps/salesforce.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module AttributeMaps 6 | 7 | # Restforce::DB::AttributeMaps::Database encapsulates the logic for 8 | # compiling and parsing normalized attribute hashes from/for Salesforce 9 | # objects. 10 | class Salesforce 11 | 12 | # Public: Initialize a Restforce::DB::AttributeMaps::Salesforce. 13 | # 14 | # fields - A Hash of mappings between database columns and fields in 15 | # Salesforce. 16 | def initialize(fields) 17 | @fields = fields 18 | end 19 | 20 | # Public: Build a normalized Hash of attributes from the appropriate set 21 | # of mappings. The keys of the resulting mapping Hash will correspond to 22 | # the Salesforce field names. 23 | # 24 | # record - The underlying Salesforce object for which attributes should 25 | # be collected. 26 | # 27 | # Returns a Hash. 28 | def attributes(record) 29 | @fields.values.each_with_object({}) do |mapping, values| 30 | values[mapping] = mapping.split(".").inject(record) do |value, portion| 31 | value[portion] 32 | end 33 | end 34 | end 35 | 36 | # Public: Convert a Hash of normalized attributes to a format suitable 37 | # for consumption by an ActiveRecord object. 38 | # 39 | # attributes - A Hash of attributes, with keys corresponding to the 40 | # normalized Salesforce attribute names. 41 | # 42 | # Examples 43 | # 44 | # attribute_map = AttributeMaps::Salesforce.new( 45 | # some_key: "SomeField__c", 46 | # ) 47 | # 48 | # mapping.convert("Object__c", "Some_Field__c" => "some other value") 49 | # # => { "Some_Field__c" => "some other value" } 50 | # 51 | # Returns a Hash. 52 | def convert(attributes) 53 | attributes.dup 54 | end 55 | 56 | end 57 | 58 | end 59 | 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /lib/restforce/db/client.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Client is a thin abstraction on top of the default 6 | # Restforce::Data::Client class, which adds support for an API endpoint 7 | # not yet supported by the base gem. 8 | class Client < ::Restforce::Data::Client 9 | 10 | # Public: Instantiate a new Restforce::DB::Client. Updates the middleware 11 | # stack to account for some additional instrumentation and automatically 12 | # retry timed out requests. 13 | def initialize(**_) 14 | super 15 | 16 | # NOTE: By default, the Retry middleware will catch timeout exceptions, 17 | # and retry up to two times. For more information, see: 18 | # https://github.com/lostisland/faraday/blob/master/lib/faraday/request/retry.rb 19 | middleware.insert( 20 | -2, 21 | Faraday::Request::Retry, 22 | methods: [:get, :head, :options, :put, :patch, :delete], 23 | ) 24 | 25 | middleware.insert_after( 26 | Restforce::Middleware::InstanceURL, 27 | FaradayMiddleware::Instrumentation, 28 | name: "request.restforce_db", 29 | ) 30 | 31 | middleware.insert_before( 32 | FaradayMiddleware::Instrumentation, 33 | Restforce::DB::Middleware::StoreRequestBody, 34 | ) 35 | end 36 | 37 | # Public: Get a list of Salesforce records which have been deleted between 38 | # the specified times. 39 | # 40 | # sobject - The Salesforce object type to query against. 41 | # start_time - A Time or Time-compatible object indicating the earliest 42 | # time for which to find deleted records. 43 | # end_time - A Time or Time-compatible object indicating the latest time 44 | # for which to find deleted records. Defaults to the current 45 | # time. 46 | # 47 | # Example 48 | # 49 | # Restforce::DB.client.get_deleted_between( 50 | # "CustomObject__c", 51 | # Time.now - 300, 52 | # Time.now, 53 | # ) 54 | # 55 | # #=> [ 56 | # #, 60 | # ] 61 | # 62 | # Returns an Array of Restforce::Mash objects. 63 | def get_deleted_between(sobject, start_time, end_time = Time.now) 64 | response = api_get( 65 | "sobjects/#{sobject}/deleted", 66 | start: start_time.utc.iso8601, 67 | end: end_time.utc.iso8601, 68 | ) 69 | 70 | Array(response.body["deletedRecords"]) 71 | end 72 | 73 | end 74 | 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /lib/restforce/db/collector.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Collector is responsible for grabbing the attributes from 6 | # recently-updated records for purposes of synchronization. It relies on the 7 | # mappings configured in instances of Restforce::DB::RecordTypes::Base to 8 | # locate recently-updated records and fetch their attributes. 9 | class Collector < Task 10 | 11 | # Public: Run the collection process, pulling in records from Salesforce 12 | # and the database to determine the lists of attributes to apply to all 13 | # mapped records. 14 | # 15 | # accumulator - A Hash-like accumulator object. 16 | # 17 | # Returns a Hash mapping Salesforce ID/type combinations to accumulators. 18 | def run(accumulator = nil) 19 | @accumulated_changes = accumulator || accumulated_changes 20 | 21 | @runner.run(@mapping) do |run| 22 | run.salesforce_instances.each { |instance| accumulate(instance) } 23 | run.database_instances.each { |instance| accumulate(instance) } 24 | end 25 | 26 | accumulated_changes 27 | ensure 28 | # Clear out the results of this run so we start fresh next time. 29 | @accumulated_changes = nil 30 | end 31 | 32 | private 33 | 34 | # Internal: Get a Hash to collect accumulated changes. 35 | # 36 | # Returns a Hash of Hashes. 37 | def accumulated_changes 38 | @accumulated_changes ||= Hash.new { |h, k| h[k] = {} } 39 | end 40 | 41 | # Internal: Append the passed instance's attributes to its accumulated list 42 | # of changesets. 43 | # 44 | # instance - A Restforce::DB::Instances::Base. 45 | # 46 | # Returns nothing. 47 | def accumulate(instance) 48 | return unless instance.synced? && @runner.changed?(instance) 49 | 50 | accumulated_changes[key_for(instance)].store( 51 | instance.last_update, 52 | instance.attributes, 53 | ) 54 | end 55 | 56 | # Internal: Get a unique key with enough information to look up the passed 57 | # instance in Salesforce. 58 | # 59 | # instance - A Restforce::DB::Instances::Base. 60 | # 61 | # Returns an Object. 62 | def key_for(instance) 63 | [instance.id, instance.mapping.salesforce_model] 64 | end 65 | 66 | end 67 | 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /lib/restforce/db/configuration.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module Restforce 4 | 5 | module DB 6 | 7 | # Restforce::DB::Configuration exposes a handful of straightforward write 8 | # and read methods to allow users to configure Restforce::DB. 9 | class Configuration 10 | 11 | DEFAULT_API_VERSION = "29.0".freeze 12 | DEFAULT_TIMEOUT = 5.freeze 13 | DEFAULT_ADAPTER = :net_http.freeze 14 | 15 | attr_accessor(*%i( 16 | username 17 | password 18 | security_token 19 | client_id 20 | client_secret 21 | host 22 | timeout 23 | adapter 24 | api_version 25 | logger 26 | )) 27 | 28 | # Public: Allow a `before` callback to be configured or run. Runs the 29 | # previously-configured block with any passed objects if called without a 30 | # block. 31 | # 32 | # args - An arbitrary collection of arguments to pass to the hook. 33 | # block - A block of code to execute after process forking. 34 | # 35 | # Returns nothing. 36 | def before(*args, &block) 37 | if block_given? 38 | Thread.current[:before_hook] = block 39 | else 40 | Thread.current[:before_hook].call(*args) if Thread.current[:before_hook] 41 | end 42 | end 43 | 44 | # Public: Get the configured logger. Returns a null logger if no logger 45 | # has been configured yet. 46 | # 47 | # Returns a Logger. 48 | def logger 49 | @logger ||= Logger.new("/dev/null") 50 | end 51 | 52 | # Public: Parse a supplied YAML file for a set of credentials, and use 53 | # them to populate the attributes on this configuraton object. 54 | # 55 | # file_path - A String or Path referencing a client configuration file. 56 | # 57 | # Returns nothing. 58 | def parse(file_path) 59 | settings = YAML.load_file(file_path) 60 | load(settings["client"]) 61 | end 62 | 63 | # Public: Populate this configuration object from a Hash of credentials. 64 | # 65 | # configurations - A Hash of credentials, with keys matching the names 66 | # of the attributes for this class. 67 | # 68 | # Returns nothing. 69 | def load(configurations) 70 | self.username = parsed(configurations, "username") 71 | self.password = parsed(configurations, "password") 72 | self.security_token = parsed(configurations, "security_token") 73 | self.client_id = parsed(configurations, "client_id") 74 | self.client_secret = parsed(configurations, "client_secret") 75 | self.host = parsed(configurations, "host") 76 | 77 | # We want to default to 29.0 or later, so we can support the API 78 | # endpoint for recently deleted records. 79 | self.api_version = configurations["api_version"] || DEFAULT_API_VERSION 80 | self.timeout = configurations["timeout"] || DEFAULT_TIMEOUT 81 | self.adapter = (configurations["adapter"] || DEFAULT_ADAPTER).to_sym 82 | end 83 | 84 | private 85 | 86 | # Internal: Get the requested setting from a Hash of configurations. 87 | # 88 | # configurations - A Hash of configurations. 89 | # setting - A String name of a single configuration in the Hash. 90 | # 91 | # Returns the value of the setting. 92 | # Raises ArgumentError if the setting is not contained in the Hash. 93 | def parsed(configurations, setting) 94 | configurations.fetch setting do |key| 95 | raise ArgumentError, "Configuration is missing #{key}" 96 | end 97 | end 98 | 99 | end 100 | 101 | end 102 | 103 | end 104 | -------------------------------------------------------------------------------- /lib/restforce/db/initializer.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Initializer is responsible for ensuring that both systems 6 | # are populated with the same records at the root level. It iterates through 7 | # recently added or updated records in each system for a mapping, and 8 | # creates a matching record in the other system, when necessary. 9 | class Initializer < Task 10 | 11 | # Public: Run the initialization loop for this mapping. 12 | # 13 | # Returns nothing. 14 | def run(*_) 15 | return if @mapping.strategy.passive? 16 | 17 | @runner.run(@mapping) do |run| 18 | run.salesforce_instances.each { |instance| create_in_database(instance) } 19 | run.database_instances.each { |instance| create_in_salesforce(instance) } 20 | end 21 | end 22 | 23 | private 24 | 25 | # Internal: Attempt to create a partner record in the database for the 26 | # passed Salesforce record. Does nothing if the Salesforce record has 27 | # already been synchronized into the system at least once. 28 | # 29 | # instance - A Restforce::DB::Instances::Salesforce. 30 | # 31 | # Returns nothing. 32 | def create_in_database(instance) 33 | return unless @mapping.strategy.build?(instance) 34 | 35 | created = @mapping.database_record_type.create!(instance) 36 | 37 | @runner.cache_timestamp instance 38 | @runner.cache_timestamp created 39 | rescue ActiveRecord::ActiveRecordError, Faraday::Error::ClientError => e 40 | DB.logger.error(SynchronizationError.new(e, instance)) 41 | end 42 | 43 | # Internal: Attempt to create a partner record in Salesforce for the 44 | # passed database record. Does nothing if the database record already has 45 | # an associated Salesforce record. 46 | # 47 | # instance - A Restforce::DB::Instances::ActiveRecord. 48 | # 49 | # Returns nothing. 50 | def create_in_salesforce(instance) 51 | return if instance.synced? 52 | 53 | created = @mapping.salesforce_record_type.create!(instance) 54 | 55 | @runner.cache_timestamp instance 56 | @runner.cache_timestamp created 57 | rescue Faraday::Error::ClientError => e 58 | DB.logger.error(SynchronizationError.new(e, instance)) 59 | end 60 | 61 | end 62 | 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /lib/restforce/db/instances/active_record.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Instances 6 | 7 | # Restforce::DB::Instances::ActiveRecord serves as a wrapper for 8 | # ActiveRecord::Base-compatible objects, exposing a common API to 9 | # reconcile record attributes with Salesforce instances. 10 | class ActiveRecord < Base 11 | 12 | # Public: Get a common identifier for this record. If the record is 13 | # unsynchronized, returns a database-specific identifier. 14 | # 15 | # Returns a String. 16 | def id 17 | return uuid unless synced? 18 | @record.send(@mapping.lookup_column) 19 | end 20 | 21 | # Public: Get a unique identifier for this record. This value should 22 | # be consistent for the specific ActiveRecord object passed to this 23 | # instance. 24 | # 25 | # Returns nothing. 26 | def uuid 27 | "#{@record_type}::#{@record.id}" 28 | end 29 | 30 | # Public: Update the instance with the passed attributes. 31 | # 32 | # attributes - A Hash mapping attribute names to values. 33 | # 34 | # Returns self. 35 | # Raises if the update fails for any reason. 36 | def update!(attributes) 37 | record.assign_attributes(attributes) 38 | return self unless record.changed? 39 | 40 | super attributes 41 | end 42 | 43 | # Public: Get the time of the last update to this record. 44 | # 45 | # Returns a Time-compatible object. 46 | def last_update 47 | @record.updated_at 48 | end 49 | 50 | # Public: Has this record been synced to a Salesforce record? 51 | # 52 | # Returns a Boolean. 53 | def synced? 54 | @record.send(:"#{@mapping.lookup_column}?") 55 | end 56 | 57 | # Public: Was this record most recently updated by Restforce::DB's 58 | # workflow? 59 | # 60 | # Returns a Boolean. 61 | def updated_internally? 62 | last_synchronize.to_i >= last_update.to_i 63 | end 64 | 65 | # Public: Bump the synchronization timestamp on the record. 66 | # 67 | # Returns nothing. 68 | def after_sync 69 | @record.touch(:synchronized_at) 70 | super 71 | end 72 | 73 | private 74 | 75 | # Internal: Get the time of the last synchronization update to this 76 | # record. 77 | # 78 | # Returns a Time-compatible object. 79 | def last_synchronize 80 | @record.synchronized_at 81 | end 82 | 83 | end 84 | 85 | end 86 | 87 | end 88 | 89 | end 90 | -------------------------------------------------------------------------------- /lib/restforce/db/instances/base.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Instances 6 | 7 | # Restforce::DB::Instances::Base defines common behavior for the other 8 | # models defined in the Restforce::DB::Instances namespace. 9 | class Base 10 | 11 | attr_reader :record, :record_type, :mapping 12 | 13 | # Public: Initialize a new Restforce::DB::Instances::Base instance. 14 | # 15 | # record_type - A String or Class describing the record's type. 16 | # record - The Salesforce or database record to manage. 17 | # mapping - An instance of Restforce::DB::Mapping. 18 | def initialize(record_type, record, mapping = nil) 19 | @record_type = record_type 20 | @record = record 21 | @mapping = mapping 22 | end 23 | 24 | # Public: Update the instance with the passed attributes. 25 | # 26 | # attributes - A Hash mapping attribute names to values. 27 | # 28 | # Returns self. 29 | # Raises if the update fails for any reason. 30 | def update!(attributes) 31 | return self if attributes.empty? 32 | 33 | record.update!(attributes) 34 | after_sync 35 | end 36 | 37 | # Public: Get a Hash mapping the configured attributes names to their 38 | # values for this instance. 39 | # 40 | # Returns a Hash. 41 | def attributes 42 | @mapping.attributes(@record_type, record) 43 | end 44 | 45 | # Public: Has this record been synced with Salesforce? 46 | # 47 | # Returns a Boolean. 48 | def synced? 49 | true 50 | end 51 | 52 | # Public: A hook which is performed after records are synchronized. 53 | # Override this method in subclasses to inject generic behaviors into 54 | # the record synchronization flow. 55 | # 56 | # Returns self. 57 | def after_sync 58 | self 59 | end 60 | 61 | end 62 | 63 | end 64 | 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /lib/restforce/db/instances/salesforce.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Instances 6 | 7 | # Restforce::DB::Instances::Salesforce serves as a wrapper for Salesforce 8 | # objects, exposing a common API to reconcile record attributes with 9 | # ActiveRecord instances. 10 | class Salesforce < Base 11 | 12 | INTERNAL_ATTRIBUTES = %w( 13 | Id 14 | SynchronizationId__c 15 | SystemModstamp 16 | LastModifiedById 17 | ).freeze 18 | 19 | # Public: Get a common identifier for this record. 20 | # 21 | # Returns a String. 22 | def id 23 | @record.Id 24 | end 25 | 26 | # Public: Update the instance with the passed attributes. 27 | # 28 | # attributes - A Hash mapping attribute names to values. 29 | # 30 | # Returns self. 31 | # Raises if the update fails for any reason. 32 | def update!(attributes) 33 | super FieldProcessor.new.process(@record_type, attributes, :update) 34 | end 35 | 36 | # Public: Get the time of the last update to this record. 37 | # 38 | # Returns a Time-compatible object. 39 | def last_update 40 | Time.parse(@record.SystemModstamp) 41 | end 42 | 43 | # Public: Has this record been synced with Salesforce? 44 | # 45 | # Returns a Boolean. 46 | def synced? 47 | @mapping.database_model.exists?(@mapping.lookup_column => id) 48 | end 49 | 50 | # Public: Was this record most recently updated by Restforce::DB's 51 | # workflow? 52 | # 53 | # Returns a Boolean. 54 | def updated_internally? 55 | @record.LastModifiedById == DB.user_id 56 | end 57 | 58 | end 59 | 60 | end 61 | 62 | end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/restforce/db/loggable.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Loggable defines shared behaviors for objects which 6 | # need access to generic logging functionality. 7 | module Loggable 8 | 9 | # Public: Add a `logger` attribute to the object including this module. 10 | # 11 | # base - The object which is including the `Loggable` module. 12 | def self.included(base) 13 | base.send :attr_accessor, :logger 14 | end 15 | 16 | private 17 | 18 | # Internal: Log the passed text at the specified level. 19 | # 20 | # text - The piece of text which should be logged for this worker. 21 | # level - The level at which the text should be logged. Defaults to :info. 22 | # 23 | # Returns nothing. 24 | def log(text, level = :info) 25 | return unless logger 26 | logger.send(level, text) 27 | end 28 | 29 | # Internal: Log an error for the worker, outputting the entire error 30 | # stacktrace and applying the appropriate log level. 31 | # 32 | # exception - An Exception object. 33 | # 34 | # Returns nothing. 35 | def error(exception) 36 | log exception, :error 37 | end 38 | 39 | end 40 | 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /lib/restforce/db/mapping.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Mapping captures a set of mappings between database columns 6 | # and Salesforce fields, providing utilities to transform hashes of 7 | # attributes from one to the other. 8 | class Mapping 9 | 10 | class InvalidMappingError < StandardError; end 11 | 12 | extend Forwardable 13 | def_delegators( 14 | :attribute_map, 15 | :attributes, 16 | :convert, 17 | ) 18 | 19 | attr_reader( 20 | :database_model, 21 | :salesforce_model, 22 | :database_record_type, 23 | :salesforce_record_type, 24 | ) 25 | 26 | attr_accessor( 27 | :adapter, 28 | :fields, 29 | :associations, 30 | :conditions, 31 | :strategy, 32 | ) 33 | 34 | # Public: Initialize a new Restforce::DB::Mapping. 35 | # 36 | # database_model - A Class compatible with ActiveRecord::Base. 37 | # salesforce_model - A String name of an object type in Salesforce. 38 | # strategy - A synchronization Strategy object. 39 | def initialize(database_model, salesforce_model, strategy = Strategies::Always.new) 40 | @database_model = database_model 41 | @salesforce_model = salesforce_model 42 | 43 | @database_record_type = RecordTypes::ActiveRecord.new(database_model, self) 44 | @salesforce_record_type = RecordTypes::Salesforce.new(salesforce_model, self) 45 | 46 | self.adapter = Adapter.new 47 | self.fields = {} 48 | self.associations = [] 49 | self.conditions = [] 50 | self.strategy = strategy 51 | end 52 | 53 | # Public: Get a list of the relevant Salesforce field names for this 54 | # mapping. 55 | # 56 | # Returns an Array. 57 | def salesforce_fields 58 | fields.values + associations.map(&:fields).flatten 59 | end 60 | 61 | # Public: Get a list of the relevant database column names for this 62 | # mapping. 63 | # 64 | # Returns an Array. 65 | def database_fields 66 | fields.keys 67 | end 68 | 69 | # Public: Get the name of the database column which should be used to 70 | # store the Salesforce lookup ID. 71 | # 72 | # Raises an InvalidMappingError if no database column exists. 73 | # Returns a Symbol. 74 | def lookup_column 75 | @lookup_column ||= begin 76 | column_prefix = salesforce_model.underscore.chomp("__c") 77 | column = :"#{column_prefix}_salesforce_id" 78 | 79 | if database_record_type.column?(column) 80 | column 81 | elsif database_record_type.column?(:salesforce_id) 82 | :salesforce_id 83 | else 84 | raise InvalidMappingError, "#{database_model} must define a Salesforce ID column" 85 | end 86 | end 87 | end 88 | 89 | # Public: Access the Mapping object without any conditions on the fetched 90 | # records. Allows for a comparison of all modified records to only those 91 | # modified records that still fit the `where` criteria. 92 | # 93 | # block - A block of code to execute in a condition-less context. 94 | # 95 | # Yields the Mapping with its conditions removed. 96 | # Returns the result of the block. 97 | def unscoped 98 | criteria = @conditions 99 | @conditions = [] 100 | yield self 101 | ensure 102 | @conditions = criteria 103 | end 104 | 105 | private 106 | 107 | # Internal: Get an AttributeMap for the fields defined for this mapping. 108 | # 109 | # Returns a Restforce::DB::AttributeMap. 110 | def attribute_map 111 | @attribute_map ||= AttributeMap.new(database_model, salesforce_model, fields, adapter) 112 | end 113 | 114 | end 115 | 116 | end 117 | 118 | end 119 | -------------------------------------------------------------------------------- /lib/restforce/db/middleware/store_request_body.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Middleware 6 | 7 | # Public: A Faraday middleware to store the request body in the environment. 8 | # 9 | # This works around an issue with Faraday where the request body is squashed by 10 | # the response body, once a request has been made. 11 | # 12 | # See also: 13 | # - https://github.com/lostisland/faraday/issues/163 14 | # - https://github.com/lostisland/faraday/issues/297 15 | class StoreRequestBody < Faraday::Middleware 16 | 17 | # Public: Executes this middleware. 18 | # 19 | # request_env - The request's Env from Faraday. 20 | def call(request_env) 21 | request_env[:request_body] = request_env[:body] 22 | @app.call(request_env) 23 | end 24 | 25 | end 26 | 27 | end 28 | 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/restforce/db/model.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Model is a helper module which attaches some special 6 | # DSL-style methods to an ActiveRecord class, allowing for easier mapping 7 | # of the ActiveRecord class to an object type in Salesforce. 8 | module Model 9 | 10 | # :nodoc: 11 | def self.included(base) 12 | base.extend(ClassMethods) 13 | end 14 | 15 | # :nodoc: 16 | module ClassMethods 17 | 18 | # Public: Initializes a Restforce::DB::Mapping defining this model's 19 | # relationship to a Salesforce object type. Passes a provided block to 20 | # the Restforce::DB::DSL for evaluation. 21 | # 22 | # salesforce_model - A String name of an object type in Salesforce. 23 | # strategy - A Symbol naming a desired initialization strategy. 24 | # options - A Hash of options to pass through to the Mapping. 25 | # block - A block of code to evaluate through the DSL. 26 | # 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) 30 | end 31 | 32 | end 33 | 34 | # Public: Force a synchronization to run for this specific record. If the 35 | # record has not yet been pushed up to Salesforce, create it. In the event 36 | # that the record has already been synchronized, force the data to be re- 37 | # synchronized. 38 | # 39 | # NOTE: To ensure that we aren't attempting to synchronize data which has 40 | # not actually been committed to the database, this method no-ops for 41 | # unpersisted records, and discards all local changes to the record prior 42 | # to syncing. 43 | # 44 | # Returns a Boolean. 45 | def force_sync! 46 | return false unless persisted? 47 | reload 48 | 49 | sync_instances.each do |instance| 50 | salesforce_record_type = instance.mapping.salesforce_record_type 51 | 52 | if instance.synced? 53 | salesforce_instance = salesforce_record_type.find(instance.id) 54 | next unless salesforce_instance 55 | 56 | accumulator = Restforce::DB::Accumulator.new 57 | accumulator.store(instance.last_update, instance.attributes) 58 | accumulator.store(salesforce_instance.last_update, salesforce_instance.attributes) 59 | 60 | synchronizer = Restforce::DB::Synchronizer.new(instance.mapping) 61 | synchronizer.update(instance, accumulator) 62 | synchronizer.update(salesforce_instance, accumulator) 63 | else 64 | salesforce_record_type.create!(instance) 65 | end 66 | end 67 | 68 | true 69 | end 70 | 71 | private 72 | 73 | # Internal: Get a collection of instances for each mapping set up on this 74 | # record's model, for use in custom synchronization code. 75 | # 76 | # Returns an Array of Restforce::DB::Instances::ActiveRecord objects. 77 | def sync_instances 78 | Restforce::DB::Registry[self.class].map do |mapping| 79 | Restforce::DB::Instances::ActiveRecord.new( 80 | mapping.database_model, 81 | self, 82 | mapping, 83 | ) 84 | end 85 | end 86 | 87 | end 88 | 89 | end 90 | 91 | end 92 | -------------------------------------------------------------------------------- /lib/restforce/db/railtie.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Railtie makes Restforce::DB's rake tasks available to any 6 | # Rails application which requires the gem. 7 | class Railtie < Rails::Railtie 8 | 9 | railtie_name :"restforce-db" 10 | 11 | rake_tasks do 12 | load "tasks/restforce.rake" 13 | end 14 | 15 | end 16 | 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /lib/restforce/db/record_cache.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::RecordCache serves as a means of caching the collections of 6 | # recently-updated database and Salesforce instances for passed mappings. 7 | # The general goal is to avoid making repetitive Salesforce API calls or 8 | # database queries, and ensure a consistent list of objects during a 9 | # synchronization run. 10 | class RecordCache 11 | 12 | # Public: Initialize a new Restforce::DB::RecordCache. 13 | def initialize 14 | reset 15 | end 16 | 17 | # Public: Iterate through the recently-updated instances of the specified 18 | # type for the passed mapping. Memoizes the records if the collection has 19 | # not previously been cached. 20 | # 21 | # mapping - A Restforce::DB::Mapping. 22 | # record_type - A Symbol naming a mapping record type. Valid values are 23 | # :salesforce_record_type or :database_record_type. 24 | # options - A Hash of options to pass to `all` (optional). 25 | # 26 | # Returns an Array of Restforce::DB::Instances::Base. 27 | def collection(mapping, record_type, options = {}) 28 | return cached_value(mapping, record_type) if cached?(mapping, record_type) 29 | cache(mapping, record_type, mapping.send(record_type).all(options)) 30 | end 31 | 32 | # Public: Reset the cache. Should be invoked between runs to ensure that 33 | # new options are respected. 34 | # 35 | # Returns nothing. 36 | def reset 37 | @cache = Hash.new { |h, k| h[k] = {} } 38 | end 39 | 40 | private 41 | 42 | # Internal: Store the supplied value in the cache for the passed mapping 43 | # and record type. 44 | # 45 | # mapping - A Restforce::DB::Mapping. 46 | # record_type - A Symbol naming a mapping record type. Valid values are 47 | # :salesforce_record_type or :database_record_type. 48 | # 49 | # Returns the cached value. 50 | def cache(mapping, record_type, value) 51 | @cache[record_type][key_for(mapping)] = value 52 | end 53 | 54 | # Internal: Get the cached collection for the passed mapping and record 55 | # type. 56 | # 57 | # mapping - A Restforce::DB::Mapping. 58 | # record_type - A Symbol naming a mapping record type. Valid values are 59 | # :salesforce_record_type or :database_record_type. 60 | # 61 | # Returns nil or an Array. 62 | def cached_value(mapping, record_type) 63 | @cache[record_type][key_for(mapping)] 64 | end 65 | 66 | # Internal: Have we cached a collection for the passed mapping and record 67 | # type? 68 | # 69 | # mapping - A Restforce::DB::Mapping. 70 | # record_type - A Symbol naming a mapping record type. Valid values are 71 | # :salesforce_record_type or :database_record_type. 72 | # 73 | # Returns a Boolean. 74 | def cached?(mapping, record_type) 75 | !cached_value(mapping, record_type).nil? 76 | end 77 | 78 | # Internal: Get a unique key with enough information to look up the passed 79 | # mapping in the cache. Scopes the mapping by its current list of 80 | # conditions. 81 | # 82 | # mapping - A Restforce::DB::Mapping. 83 | # 84 | # Returns an Object. 85 | def key_for(mapping) 86 | [mapping, mapping.conditions] 87 | end 88 | 89 | end 90 | 91 | end 92 | 93 | end 94 | -------------------------------------------------------------------------------- /lib/restforce/db/record_types/base.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module RecordTypes 6 | 7 | # Restforce::DB::RecordTypes::Base defines common behavior for the other 8 | # models defined in the Restforce::DB::RecordTypes namespace. 9 | class Base 10 | 11 | # Public: Initialize a new Restforce::DB::RecordTypes::Base. 12 | # 13 | # record_type - The name or class of the system record type. 14 | # mapping - A Restforce::DB::Mapping. 15 | def initialize(record_type, mapping = nil) 16 | @record_type = record_type 17 | @mapping = mapping 18 | end 19 | 20 | end 21 | 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/restforce/db/registry.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Registry is responsible for keeping track of all mappings 6 | # established in the system. 7 | class Registry 8 | 9 | extend Enumerable 10 | 11 | # Public: Get the Restforce::DB::Mapping entry for the specified model. 12 | # 13 | # model - A String or Class. 14 | # 15 | # Returns a Restforce::DB::Mapping. 16 | def self.[](model) 17 | collection[model] 18 | end 19 | 20 | # Public: Iterate through all registered Restforce::DB::Mappings. 21 | # 22 | # Yields one Mapping for each database-to-Salesforce mapping. 23 | # Returns nothing. 24 | def self.each 25 | collection.each do |model, mappings| 26 | # Since each mapping is inserted twice, we ignore the half which 27 | # were inserted via Salesforce model names. 28 | next unless model.is_a?(Class) 29 | 30 | mappings.each do |mapping| 31 | yield mapping 32 | end 33 | end 34 | end 35 | 36 | # Public: Add a mapping to the overarching Mapping collection. Appends 37 | # the mapping to the collection for both its database and salesforce 38 | # object types. 39 | # 40 | # mapping - A Restforce::DB::Mapping. 41 | # 42 | # Returns nothing. 43 | def self.<<(mapping) 44 | [mapping.database_model, mapping.salesforce_model].each do |model| 45 | collection[model] << mapping 46 | end 47 | end 48 | 49 | # Public: Clear out any existing registered mappings. 50 | # 51 | # Returns nothing. 52 | def self.clean! 53 | @collection = nil 54 | end 55 | 56 | # Public: Get the collection of currently-registered mappings. 57 | # 58 | # Returns a Hash. 59 | def self.collection 60 | @collection ||= Hash.new { |h, k| h[k] = [] } 61 | end 62 | 63 | end 64 | 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /lib/restforce/db/runner.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Runner provides an abstraction for lookup timing during the 6 | # synchronization process. It provides methods for accessing only recently- 7 | # modified records within the context of a specific Mapping. 8 | class Runner 9 | 10 | attr_reader :last_run 11 | attr_accessor :before, :after 12 | 13 | extend Forwardable 14 | def_delegators( 15 | :@timestamp_cache, 16 | :cache_timestamp, 17 | :changed?, 18 | :dump_timestamps, 19 | :load_timestamps, 20 | ) 21 | 22 | # Public: Initialize a new Restforce::DB::Runner. 23 | # 24 | # delay - A Numeric offet to apply to all record lookups. Can be 25 | # used to mitigate server timing issues. 26 | # last_run_time - A Time indicating the point at which new runs should 27 | # begin. 28 | def initialize(delay = 0, last_run_time = DB.last_run) 29 | @delay = delay 30 | @last_run = last_run_time 31 | @record_cache = RecordCache.new 32 | @timestamp_cache = TimestampCache.new 33 | end 34 | 35 | # Public: Indicate that a new phase of the run is beginning. Updates the 36 | # before/after timestamp to ensure that new lookups are properly filtered. 37 | # 38 | # Returns the new run Time. 39 | def tick! 40 | @record_cache.reset 41 | @timestamp_cache.reset 42 | 43 | run_time = Time.now 44 | 45 | @before = run_time - @delay 46 | @after = last_run - @delay if @last_run 47 | 48 | @last_run = run_time 49 | end 50 | 51 | # Public: Grant access to recently-updated records for a specific mapping. 52 | # 53 | # mapping - A Restforce::DB::Mapping instance. 54 | # 55 | # Yields self, in the context of the passed mapping. 56 | # Returns nothing. 57 | def run(mapping) 58 | @mapping = mapping 59 | yield self 60 | ensure 61 | @mapping = nil 62 | end 63 | 64 | # Public: Iterate through recently-updated records for the Salesforce 65 | # record type defined by the current mapping. 66 | # 67 | # cached - A Boolean reflecting whether or not the collection should be 68 | # fetched through this runner's RecordCache. 69 | # 70 | # Returns an Enumerator yielding Restforce::DB::Instances::Salesforces. 71 | def salesforce_instances(cached = true) 72 | if cached 73 | @record_cache.collection(@mapping, :salesforce_record_type, options) 74 | else 75 | @mapping.salesforce_record_type.all(options) 76 | end 77 | end 78 | 79 | # Public: Iterate through recently-updated records for the database model 80 | # record type defined by the current mapping. 81 | # 82 | # cached - A Boolean reflecting whether or not the collection should be 83 | # fetched through this runner's RecordCache. 84 | # 85 | # Returns an Enumerator yielding Restforce::DB::Instances::ActiveRecords. 86 | def database_instances(cached = true) 87 | if cached 88 | @record_cache.collection(@mapping, :database_record_type, options) 89 | else 90 | @mapping.database_record_type.all(options) 91 | end 92 | end 93 | 94 | private 95 | 96 | # Internal: Get a Hash of options to apply to record lookups. 97 | # 98 | # Returns a Hash. 99 | def options 100 | { after: after, before: before } 101 | end 102 | 103 | end 104 | 105 | end 106 | 107 | end 108 | -------------------------------------------------------------------------------- /lib/restforce/db/strategies/always.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Strategies 6 | 7 | # Restforce::DB::Strategies::Always defines an initialization strategy for 8 | # a mapping in which newly-discovered records should always be 9 | # synchronized from Salesforce into the database, and vice-versa. 10 | class Always 11 | 12 | # Public: Initialize a Restforce::DB::Strategies::Always. 13 | def initialize(**_) 14 | end 15 | 16 | # Public: Should the passed record be constructed in the other system? 17 | # 18 | # record - A Restforce::DB::Instances::Base. 19 | # 20 | # Returns a Boolean. 21 | def build?(record) 22 | !record.synced? 23 | end 24 | 25 | # Public: Is this a passive sync strategy? 26 | # 27 | # Returns false. 28 | def passive? 29 | false 30 | end 31 | 32 | end 33 | 34 | end 35 | 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /lib/restforce/db/strategies/associated.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Strategies 6 | 7 | # Restforce::DB::Strategies::Associated defines an initialization strategy 8 | # for a mapping in which newly-discovered records should only be 9 | # synchronized into the other system when a specific associated record 10 | # has already been synchronized. 11 | class Associated 12 | 13 | # Public: Initialize a Restforce::DB::Strategies::Associated for the 14 | # passed mapping. 15 | # 16 | # with - A Symbol name of the association which should be checked. 17 | def initialize(with:) 18 | @association = with.to_sym 19 | end 20 | 21 | # Public: Should the passed record be constructed in the other system? 22 | # 23 | # record - A Restforce::DB::Instances::Base. 24 | # 25 | # Returns a Boolean. 26 | def build?(record) 27 | !record.synced? && target_association(record.mapping).synced_for?(record) 28 | end 29 | 30 | # Public: Is this a passive sync strategy? 31 | # 32 | # Returns false. 33 | def passive? 34 | false 35 | end 36 | 37 | private 38 | 39 | # Internal: Get the target association for the desired associated record 40 | # lookup. 41 | # 42 | # mapping - A Restforce::DB::Mapping 43 | # 44 | # Returns a Restforce::DB::Associations::Base. 45 | def target_association(mapping) 46 | @target_association ||= mapping.associations.detect do |association| 47 | association.name == @association 48 | end 49 | @target_association || raise(ArgumentError, ":with must correspond to a defined association") 50 | end 51 | 52 | end 53 | 54 | end 55 | 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /lib/restforce/db/strategies/passive.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | module Strategies 6 | 7 | # Restforce::DB::Strategies::Passive defines an initialization strategy 8 | # for a mapping in which newly-discovered records should never be 9 | # synchronized into the other system. This strategy may be used to prevent 10 | # multiple insertion points from existing for a single database record. 11 | class Passive 12 | 13 | # Public: Initialize a Restforce::DB::Strategies::Passive. 14 | def initialize(**_) 15 | end 16 | 17 | # Public: Should the passed record be constructed in the other system? 18 | # 19 | # Returns false. 20 | def build?(_) 21 | false 22 | end 23 | 24 | # Public: Is this a passive sync strategy? 25 | # 26 | # Returns true. 27 | def passive? 28 | true 29 | end 30 | 31 | end 32 | 33 | end 34 | 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /lib/restforce/db/strategy.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Strategy is an abstraction for the available 6 | # synchronization strategies, and provides a factory method by which to 7 | # obtain a strategy by name. 8 | class Strategy 9 | 10 | # Public: Get a Strategy by the requested name. 11 | # 12 | # name - The Symbol or String name of the desired strategy. 13 | # options - A Hash of options to pass to the strategy's initializer. 14 | # 15 | # Returns a Restforce::DB::Strategies instance. 16 | def self.for(name, options = {}) 17 | class_name = "Restforce::DB::Strategies::#{name.to_s.camelize}" 18 | class_name.constantize.new(options) 19 | end 20 | 21 | end 22 | 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/restforce/db/synchronization_error.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::SynchronizationError is a thin wrapper for any sort of 6 | # exception that might crop up during our record synchronization. It exposes 7 | # the Salesforce ID (or database identifier, for unsynced records) of the 8 | # record which triggered the exception. 9 | class SynchronizationError < RuntimeError 10 | 11 | attr_reader :base_exception 12 | 13 | extend Forwardable 14 | def_delegators( 15 | :base_exception, 16 | :class, 17 | :backtrace, 18 | ) 19 | 20 | # Public: Initialize a new SynchronizationError. 21 | # 22 | # base_exception - An exception which should be logged. 23 | # instance - A Restforce::DB::Instances::Base representing a record. 24 | def initialize(base_exception, instance) 25 | @base_exception = base_exception 26 | @instance = instance 27 | end 28 | 29 | # Public: Get the message for this exception. Prepends the Salesforce ID. 30 | # 31 | # Returns a String. 32 | def message 33 | debug_info = [ 34 | @instance.mapping.database_model, 35 | @instance.mapping.salesforce_model, 36 | @instance.id, 37 | ] 38 | 39 | "[#{debug_info.join('|')}] #{base_exception.message}" 40 | end 41 | 42 | end 43 | 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /lib/restforce/db/synchronizer.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Synchronizer is responsible for synchronizing the records 6 | # in Salesforce with the records in the database. It relies on the mappings 7 | # configured in instances of Restforce::DB::RecordTypes::Base to create and 8 | # update records with the appropriate values. 9 | class Synchronizer < Task 10 | 11 | # Public: Synchronize records for the current mapping from a Hash of 12 | # record descriptors to attributes. 13 | # 14 | # NOTE: Synchronizer assumes that the propagation step has done its job 15 | # correctly. If we can't locate a database record for a specific 16 | # Salesforce ID, we assume it shouldn't be synchronized. 17 | # 18 | # changes - A Hash, with keys composed of a Salesforce ID and model name, 19 | # with Restforce::DB::Accumulator objects as values. 20 | # 21 | # Returns nothing. 22 | def run(changes) 23 | changes.each do |(id, salesforce_model), accumulator| 24 | next unless salesforce_model == @mapping.salesforce_model 25 | 26 | database_instance = @mapping.database_record_type.find(id) 27 | next unless database_instance && up_to_date?(database_instance, accumulator) 28 | 29 | salesforce_instance = @mapping.salesforce_record_type.find(id) 30 | next unless salesforce_instance && up_to_date?(salesforce_instance, accumulator) 31 | 32 | update(database_instance, accumulator) 33 | update(salesforce_instance, accumulator) 34 | end 35 | end 36 | 37 | # Public: Update the passed instance with the accumulated attributes 38 | # from a synchronization run. 39 | # 40 | # instance - An instance of Restforce::DB::Instances::Base. 41 | # accumulator - A Restforce::DB::Accumulator. 42 | # 43 | # Returns nothing. 44 | def update(instance, accumulator) 45 | return unless accumulator.changed?(instance.attributes) 46 | 47 | current_attributes = accumulator.current(instance.attributes) 48 | attributes = @mapping.convert(instance.record_type, current_attributes) 49 | 50 | instance.update!(attributes) 51 | @runner.cache_timestamp instance 52 | rescue ActiveRecord::ActiveRecordError, Faraday::Error::ClientError => e 53 | DB.logger.error(SynchronizationError.new(e, instance)) 54 | end 55 | 56 | private 57 | 58 | # Internal: Is the passed instance up-to-date with the passed accumulator? 59 | # Defaults to true if the most recent change to the instance was by the 60 | # Restforce::DB worker. 61 | # 62 | # Returns a Boolean. 63 | def up_to_date?(instance, accumulator) 64 | instance.updated_internally? || accumulator.up_to_date_for?(instance.last_update) 65 | end 66 | 67 | end 68 | 69 | end 70 | 71 | end 72 | -------------------------------------------------------------------------------- /lib/restforce/db/task.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Task is a lightweight interface for task classes which 6 | # expose pieces of functionality to a master worker process. Each task 7 | # should accept a mapping and a runner, and expose a #run method to interact 8 | # with the runner's data in some way. 9 | class Task 10 | 11 | # Public: Initialize a Restforce::DB::Task. 12 | # 13 | # mapping - A Restforce::DB::Mapping. 14 | # runner - A Restforce::DB::Runner. 15 | def initialize(mapping, runner = Runner.new) 16 | @mapping = mapping 17 | @runner = runner 18 | end 19 | 20 | # Public: Run this task. Must be overridden by subclasses. 21 | # 22 | # Raises NotImplementedError. 23 | # Returns nothing. 24 | def run(*_) 25 | raise NotImplementedError 26 | end 27 | 28 | end 29 | 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /lib/restforce/db/task_manager.rb: -------------------------------------------------------------------------------- 1 | require "restforce/db/loggable" 2 | require "restforce/db/task" 3 | require "restforce/db/accumulator" 4 | require "restforce/db/attacher" 5 | require "restforce/db/associator" 6 | require "restforce/db/cleaner" 7 | require "restforce/db/collector" 8 | require "restforce/db/initializer" 9 | require "restforce/db/synchronizer" 10 | 11 | module Restforce 12 | 13 | # :nodoc: 14 | module DB 15 | 16 | # TaskMapping is a small data structure used to pass top-level task 17 | # information through to a SynchronizationError when necessary. 18 | TaskMapping = Struct.new(:id, :mapping) 19 | 20 | # Restforce::DB::TaskManager defines the run sequence and invocation of each 21 | # of the Restforce::DB::Task subclasses during a single processing loop for 22 | # the top-level Worker object. 23 | class TaskManager 24 | 25 | include Loggable 26 | 27 | # Public: Initialize a new Restforce::DB::TaskManager for a given runner 28 | # state. 29 | # 30 | # runner - A Restforce::DB::Runner for a specific period of time. 31 | # logger - A Logger object (optional). 32 | def initialize(runner, logger: nil) 33 | @runner = runner 34 | @logger = logger 35 | @changes = Hash.new { |h, k| h[k] = Accumulator.new } 36 | end 37 | 38 | # Public: Run each of the sync tasks in a defined order for the supplied 39 | # runner's current state. 40 | # 41 | # Returns nothing. 42 | def perform 43 | Registry.each do |mapping| 44 | run("CLEANING RECORDS", Cleaner, mapping) 45 | run("ATTACHING RECORDS", Attacher, mapping) 46 | run("PROPAGATING RECORDS", Initializer, mapping) 47 | run("COLLECTING CHANGES", Collector, mapping) 48 | end 49 | 50 | # NOTE: We can only perform the synchronization after all record changes 51 | # have been aggregated, so this second loop is necessary. 52 | Registry.each do |mapping| 53 | run("UPDATING ASSOCIATIONS", Associator, mapping) 54 | run("APPLYING CHANGES", Synchronizer, mapping) 55 | end 56 | end 57 | 58 | private 59 | 60 | # Internal: Log a description and response time for a specific named task. 61 | # 62 | # NOTE: AuthenticationErrors from Restforce's middleware seem to be linked 63 | # to thread-safety issues, so we want to error out the entire processing 64 | # loop in the event that we run into one of these. This is the reason for 65 | # the fairly convoluted `rescue` chain below. 66 | # 67 | # name - A String task name. 68 | # task_class - A Restforce::DB::Task subclass. 69 | # mapping - A Restforce::DB::Mapping. 70 | # 71 | # Returns a Boolean. 72 | def run(name, task_class, mapping) 73 | log " #{name} between #{mapping.database_model.name} and #{mapping.salesforce_model}" 74 | runtime = Benchmark.realtime { task task_class, mapping } 75 | log format(" FINISHED #{name} after %.4f", runtime) 76 | 77 | true 78 | rescue Restforce::AuthenticationError => e 79 | log e, :warn 80 | raise e 81 | rescue => e 82 | error(e) 83 | false 84 | rescue Exception => e # rubocop:disable Lint/RescueException 85 | error(e) 86 | raise e 87 | end 88 | 89 | # Internal: Run the passed mapping through the supplied Task class. 90 | # 91 | # task_class - A Restforce::DB::Task subclass. 92 | # mapping - A Restforce::DB::Mapping. 93 | # 94 | # Returns nothing. 95 | def task(task_class, mapping) 96 | task_class.new(mapping, @runner).run(@changes) 97 | rescue Faraday::Error::ClientError => e 98 | task_mapping = TaskMapping.new(task_class, mapping) 99 | error SynchronizationError.new(e, task_mapping) 100 | end 101 | 102 | end 103 | 104 | end 105 | 106 | end 107 | -------------------------------------------------------------------------------- /lib/restforce/db/timestamp_cache.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::TimestampCache serves to cache the timestamps of the most 6 | # recent known updates to records through the Restforce::DB system. It 7 | # allows for more intelligent decision-making regarding what constitutes 8 | # "stale" data during a synchronization. 9 | # 10 | # While we can tell which user _triggered_ the most recent changes to a 11 | # record in Salesforce, we can't tell if any modifications to that record 12 | # were a result of a background Apex trigger or workflow (which apply any 13 | # changes as if they were the user whose actions initiated the callback). 14 | # 15 | # In order to distinguish between updates made _by_ the worker and updates 16 | # made _in response to_ changes by the worker, we have to check the 17 | # record's update timestamp against the timestamp of the last known update 18 | # made by the system. This class serves as a mechanism to track the values 19 | # for this comparison. 20 | class TimestampCache 21 | 22 | # Public: Initialize a new Restforce::DB::TimestampCache. 23 | def initialize 24 | reset 25 | end 26 | 27 | # Public: Add a known update timestamp to the cache for the passed object. 28 | # 29 | # instance - A Restforce::DB::Instances::Base. 30 | # 31 | # Returns an Array of Restforce::DB::Instances::Base. 32 | def cache_timestamp(instance) 33 | @cache[key_for(instance)] = instance.last_update 34 | end 35 | 36 | # Public: Get the most recently-stored timestamp for the passed object. 37 | # Falls back to the retired timestamps to ensure that this run is aware of 38 | # the modifications made during the previous run. 39 | # 40 | # instance - A Restforce::DB::Instances::Base. 41 | # 42 | # Returns a Time or nil. 43 | def timestamp(instance) 44 | key = key_for(instance) 45 | @cache.fetch(key) { @retired_cache[key] } 46 | end 47 | 48 | # Public: Has the passed instance been modified since the last known 49 | # system-triggered update? This accounts for changes possibly introduced 50 | # by callbacks and triggers. 51 | # 52 | # instance - A Restforce::DB::Instances::Base. 53 | # 54 | # Returns a Boolean. 55 | def changed?(instance) 56 | return true unless instance.updated_internally? 57 | 58 | last_update = timestamp(instance) 59 | return true unless last_update 60 | 61 | instance.last_update > last_update 62 | end 63 | 64 | # Public: Reset the cache. Expires the previously-cached timestamps, and 65 | # retires the currently-cached timestamps to ensure that they are only 66 | # factored into the current synchronization run. 67 | # 68 | # Returns nothing. 69 | def reset 70 | @retired_cache = @cache || {} 71 | @cache = {} 72 | end 73 | 74 | # Public: Load the previous collection of cached timestamps from the 75 | # passed readable object. 76 | # 77 | # io - An IO object opened for reading. 78 | # 79 | # Returns nothing. 80 | def load_timestamps(io) 81 | @cache = YAML.load(io.read) || {} 82 | end 83 | 84 | # Public: Dump the currently cached timestamps into the specified 85 | # writable object. 86 | # 87 | # io - An IO object opened for writing. 88 | # 89 | # Returns nothing. 90 | def dump_timestamps(io) 91 | io.write(YAML.dump(@cache)) 92 | end 93 | 94 | private 95 | 96 | # Internal: Get a unique cache key for the passed instance. 97 | # 98 | # instance - A Restforce::DB::Instances::Base. 99 | # 100 | # Returns an Object. 101 | def key_for(instance) 102 | [instance.record_type, instance.id] 103 | end 104 | 105 | end 106 | 107 | end 108 | 109 | end 110 | -------------------------------------------------------------------------------- /lib/restforce/db/tracker.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | module DB 4 | 5 | # Restforce::DB::Tracker encapsulates a minimal API to track and configure 6 | # synchronization runtimes. It allows Restforce::DB to persist a "last 7 | # successful sync" timestamp. 8 | class Tracker 9 | 10 | attr_reader :last_run 11 | 12 | # Public: Initialize a Restforce::DB::Tracker. Sets a last_run timestamp 13 | # on Restforce::DB if the supplied tracking file already contains a stamp. 14 | # 15 | # file_path - The Path to the tracking file. 16 | def initialize(file_path) 17 | @file_path = file_path 18 | 19 | timestamp = File.open(@file_path, "a+") { |file| file.read } 20 | return if timestamp.empty? 21 | 22 | @last_run = Time.parse(timestamp) 23 | Restforce::DB.last_run = @last_run 24 | end 25 | 26 | # Public: Persist the passed time in the tracker file. 27 | # 28 | # time - A Time object. 29 | # 30 | # Returns nothing. 31 | def track(time) 32 | @last_run = time 33 | File.open(@file_path, "w") { |file| file.write(time.utc.iso8601) } 34 | end 35 | 36 | end 37 | 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /lib/restforce/db/version.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | # :nodoc: 4 | module DB 5 | 6 | VERSION = "4.1.1" 7 | 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /lib/restforce/extensions.rb: -------------------------------------------------------------------------------- 1 | module Restforce 2 | 3 | # :nodoc: 4 | class Middleware::Authentication < Restforce::Middleware # rubocop:disable Style/ClassAndModuleChildren 5 | 6 | # Internal: Get an error message for the passed response. Overrides the 7 | # default behavior of the middleware to correctly handle broken responses 8 | # from Faraday. 9 | # 10 | # Returns a String. 11 | def error_message(response) 12 | if response.status == 0 13 | "Request was closed prematurely" 14 | else 15 | "#{response.body['error']}: #{response.body['error_description']}" 16 | end 17 | end 18 | 19 | end 20 | 21 | # :nodoc: 22 | class SObject 23 | 24 | # Public: Update the Salesforce record with the passed attributes. 25 | # 26 | # attributes - A Hash of attributes to assign to the record. 27 | # 28 | # Raises on update error. 29 | def update!(attributes) 30 | ensure_id 31 | response = @client.api_patch("sobjects/#{sobject_type}/#{self.Id}", attributes) 32 | update_time = response.env.response_headers["date"] 33 | 34 | merge!(attributes) 35 | merge!("SystemModstamp" => update_time) 36 | end 37 | 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /lib/tasks/restforce.rake: -------------------------------------------------------------------------------- 1 | namespace :restforce do 2 | desc "Populate all records for a specific model within the specified timespan" 3 | task :seed, [:model, :start_time, :end_time, :config] => :environment do |_, args| 4 | raise ArgumentError, "the name of an ActiveRecord model must be supplied" unless args[:model] 5 | 6 | config_file = args[:config] || Rails.root.join("config", "restforce-db.yml") 7 | Restforce::DB.configure { |config| config.parse(config_file) } 8 | 9 | runner = Restforce::DB::Runner.new 10 | runner.after = Time.parse(args[:start_time]) if args[:start_time].present? 11 | runner.before = Time.parse(args[:end_time]) if args[:end_time].present? 12 | 13 | target_class = args[:model].constantize 14 | Restforce::DB::Registry[target_class].each do |mapping| 15 | puts "SYNCHRONIZING between #{mapping.database_model.name} and #{mapping.salesforce_model}" 16 | Restforce::DB::Initializer.new(mapping, runner).run 17 | puts "DONE" 18 | end 19 | end 20 | 21 | desc "Pull down the requested Salesforce data for the specified model" 22 | task :populate, [:model, :salesforce_model, :field] => :environment do |_, args| 23 | raise ArgumentError, "An ActiveRecord model name must be supplied" unless args[:model] 24 | raise ArgumentError, "A Salesforce model name must be supplied" unless args[:salesforce_model] 25 | raise ArgumentError, "An attribute name must be supplied" unless args[:field] 26 | 27 | Rails.application.eager_load! 28 | 29 | model = args[:model].constantize 30 | field = args[:field].to_sym 31 | 32 | mapping = Restforce::DB::Registry[model].detect do |m| 33 | m.salesforce_model == args[:salesforce_model] 34 | end 35 | 36 | raise ArgumentError, "No Mapping was found between #{args[:model]} and #{args[:salesforce_model]}" unless mapping 37 | 38 | model.where.not(mapping.lookup_column => nil).find_each do |record| 39 | salesforce_id = record.send(mapping.lookup_column) 40 | salesforce_instance = mapping.salesforce_record_type.find(salesforce_id) 41 | next unless salesforce_instance 42 | 43 | attributes = mapping.convert(model, salesforce_instance.attributes) 44 | record.update_columns field => attributes[field] 45 | end 46 | end 47 | 48 | desc "Get the 18-character version of a 15-character Salesforce ID" 49 | task :convertid, [:salesforce_id] do |_, args| 50 | sfid = args[:salesforce_id] 51 | raise ArgumentError, "Provide a Salesforce ID (restforce:convertid[])" if sfid.nil? 52 | 53 | puts Restforce::DB.hashed_id(sfid) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /restforce-db.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "restforce/db/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "restforce-db" 8 | spec.version = Restforce::DB::VERSION 9 | spec.authors = ["Andrew Horner"] 10 | spec.email = ["andrew@tablexi.com"] 11 | 12 | spec.summary = "Bind your database to Salesforce data" 13 | spec.description = " 14 | This gem provides two-way bindings between Salesforce records and records 15 | in an ActiveRecord-compatible database. It leans on the Restforce library 16 | for Salesforce API interactions, and provides a self-daemonizing binary 17 | which keeps records in sync by way of a tight polling loop." 18 | 19 | spec.homepage = "https://www.github.com/tablexi/restforce-db" 20 | spec.license = "MIT" 21 | 22 | spec.files = `git ls-files`.split($RS) 23 | spec.bindir = "exe" 24 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 25 | spec.require_paths = ["lib"] 26 | 27 | spec.add_dependency "activerecord" 28 | spec.add_dependency "daemons" 29 | spec.add_dependency "restforce" 30 | 31 | spec.add_development_dependency "bundler" 32 | spec.add_development_dependency "database_cleaner" 33 | spec.add_development_dependency "minitest", "5.5.1" 34 | spec.add_development_dependency "minitest-spec-expect" 35 | spec.add_development_dependency "minitest-vcr" 36 | spec.add_development_dependency "rake", "~> 10.0" 37 | 38 | # We're locking rubocop at 0.31.0 until a trailing comma issue is resolved: 39 | # https://github.com/bbatsov/rubocop/issues/1955 40 | spec.add_development_dependency "rubocop", "~> 0.31.0" 41 | spec.add_development_dependency "sqlite3" 42 | spec.add_development_dependency "webmock" 43 | end 44 | -------------------------------------------------------------------------------- /test/cassettes/Restforce_DB/accessing_Salesforce/uses_the_configured_credentials.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https:///services/oauth2/token 6 | body: 7 | encoding: US-ASCII 8 | string: grant_type=password&client_id=&client_secret=&username=&password= 9 | headers: 10 | User-Agent: 11 | - Faraday v0.9.1 12 | Content-Type: 13 | - application/x-www-form-urlencoded 14 | Accept-Encoding: 15 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 16 | Accept: 17 | - "*/*" 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Thu, 25 Jun 2015 21:53:28 GMT 25 | Set-Cookie: 26 | - BrowserId=epve6lo8RB-gti1Mgr1JhQ;Path=/;Domain=.salesforce.com;Expires=Mon, 27 | 24-Aug-2015 21:53:28 GMT 28 | Expires: 29 | - Thu, 01 Jan 1970 00:00:00 GMT 30 | Pragma: 31 | - no-cache 32 | Cache-Control: 33 | - no-cache, no-store 34 | Content-Type: 35 | - application/json;charset=UTF-8 36 | Transfer-Encoding: 37 | - chunked 38 | body: 39 | encoding: ASCII-8BIT 40 | string: '{"id":"https://login.salesforce.com/id/00D1a000000H3O9EAK/0051a000000UGT8AAO","issued_at":"1435269208465","token_type":"Bearer","instance_url":"https://","signature":"kvxZYp0syv636lBtZf4trNMGk90eUXO/7fBlkGBcTzA=","access_token":"00D1a000000H3O9!AQ4AQIEnz7RMa1N2z10U8y.cU3CAaZeOCxcDVSetug6psPcDYNjSbdC91y8MHqmZ.ZXE_zkQURv2YNCWYEsl0fcbZwb1MPEA"}' 41 | http_version: 42 | recorded_at: Thu, 25 Jun 2015 21:53:29 GMT 43 | recorded_with: VCR 2.9.3 44 | -------------------------------------------------------------------------------- /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_no_value_in_the_hash.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https:///services/oauth2/token 6 | body: 7 | encoding: US-ASCII 8 | string: grant_type=password&client_id=&client_secret=&username=&password= 9 | headers: 10 | User-Agent: 11 | - Faraday v0.9.1 12 | Content-Type: 13 | - application/x-www-form-urlencoded 14 | Accept-Encoding: 15 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 16 | Accept: 17 | - "*/*" 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Wed, 01 Jul 2015 22:21:59 GMT 25 | Set-Cookie: 26 | - BrowserId=pSV4lvHNQnW2bmTjXxurMA;Path=/;Domain=.salesforce.com;Expires=Sun, 27 | 30-Aug-2015 22:21:59 GMT 28 | Expires: 29 | - Thu, 01 Jan 1970 00:00:00 GMT 30 | Pragma: 31 | - no-cache 32 | Cache-Control: 33 | - no-cache, no-store 34 | Content-Type: 35 | - application/json;charset=UTF-8 36 | Transfer-Encoding: 37 | - chunked 38 | body: 39 | encoding: ASCII-8BIT 40 | string: '{"id":"https://login.salesforce.com/id/00D1a000000H3O9EAK/0051a000000UGT8AAO","issued_at":"1435789319584","token_type":"Bearer","instance_url":"https://","signature":"IR8FX61zjomHUYcHXhnxA8c2OlpArSm/29OXS6TXI5A=","access_token":"00D1a000000H3O9!AQ4AQKJZZLBvLm3vEyoa1I_FUwCVKJTQydGXcIOYHXeuNRsEB5WSOXQC2x.kIQ3wbc1V_KhljRRKg43aIkvmAoiG.a4Ll49g"}' 41 | http_version: 42 | recorded_at: Wed, 01 Jul 2015 22:21:59 GMT 43 | - request: 44 | method: post 45 | uri: https:///services/data//sobjects/CustomObject__c 46 | body: 47 | encoding: UTF-8 48 | string: '{"Name":"Sample object"}' 49 | headers: 50 | User-Agent: 51 | - Faraday v0.9.1 52 | Content-Type: 53 | - application/json 54 | Authorization: 55 | - OAuth 00D1a000000H3O9!AQ4AQKJZZLBvLm3vEyoa1I_FUwCVKJTQydGXcIOYHXeuNRsEB5WSOXQC2x.kIQ3wbc1V_KhljRRKg43aIkvmAoiG.a4Ll49g 56 | Accept-Encoding: 57 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 58 | Accept: 59 | - "*/*" 60 | response: 61 | status: 62 | code: 201 63 | message: Created 64 | headers: 65 | Date: 66 | - Wed, 01 Jul 2015 22:22:00 GMT 67 | Set-Cookie: 68 | - BrowserId=U6tImumuReGJF-zS-Ebehg;Path=/;Domain=.salesforce.com;Expires=Sun, 69 | 30-Aug-2015 22:22:00 GMT 70 | Expires: 71 | - Thu, 01 Jan 1970 00:00:00 GMT 72 | Sforce-Limit-Info: 73 | - api-usage=7/15000 74 | Location: 75 | - "/services/data//sobjects/CustomObject__c/a001a00000309IXAAY" 76 | Content-Type: 77 | - application/json;charset=UTF-8 78 | Transfer-Encoding: 79 | - chunked 80 | body: 81 | encoding: ASCII-8BIT 82 | string: '{"id":"a001a00000309IXAAY","success":true,"errors":[]}' 83 | http_version: 84 | recorded_at: Wed, 01 Jul 2015 22:22:00 GMT 85 | - request: 86 | method: delete 87 | uri: https:///services/data//sobjects/CustomObject__c/a001a00000309IXAAY 88 | body: 89 | encoding: US-ASCII 90 | string: '' 91 | headers: 92 | User-Agent: 93 | - Faraday v0.9.1 94 | Authorization: 95 | - OAuth 00D1a000000H3O9!AQ4AQKJZZLBvLm3vEyoa1I_FUwCVKJTQydGXcIOYHXeuNRsEB5WSOXQC2x.kIQ3wbc1V_KhljRRKg43aIkvmAoiG.a4Ll49g 96 | Accept-Encoding: 97 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 98 | Accept: 99 | - "*/*" 100 | response: 101 | status: 102 | code: 204 103 | message: No Content 104 | headers: 105 | Date: 106 | - Wed, 01 Jul 2015 22:22:01 GMT 107 | Set-Cookie: 108 | - BrowserId=ExR5pJi2RHGsmv5WrGqJ6g;Path=/;Domain=.salesforce.com;Expires=Sun, 109 | 30-Aug-2015 22:22:01 GMT 110 | Expires: 111 | - Thu, 01 Jan 1970 00:00:00 GMT 112 | Sforce-Limit-Info: 113 | - api-usage=7/15000 114 | body: 115 | encoding: UTF-8 116 | string: '' 117 | http_version: 118 | recorded_at: Wed, 01 Jul 2015 22:22:01 GMT 119 | recorded_with: VCR 2.9.3 120 | -------------------------------------------------------------------------------- /test/cassettes/Restforce_DB_Associations_BelongsTo/with_an_inverse_mapping/_lookups/when_there_is_currently_no_associated_record/returns_no_value_in_the_hash.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https:///services/oauth2/token 6 | body: 7 | encoding: US-ASCII 8 | string: grant_type=password&client_id=&client_secret=&username=&password= 9 | headers: 10 | User-Agent: 11 | - Faraday v0.9.1 12 | Content-Type: 13 | - application/x-www-form-urlencoded 14 | Accept-Encoding: 15 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 16 | Accept: 17 | - "*/*" 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Wed, 01 Jul 2015 22:22:03 GMT 25 | Set-Cookie: 26 | - BrowserId=qGHLRZezTjOQaFra2D9NGg;Path=/;Domain=.salesforce.com;Expires=Sun, 27 | 30-Aug-2015 22:22:03 GMT 28 | Expires: 29 | - Thu, 01 Jan 1970 00:00:00 GMT 30 | Pragma: 31 | - no-cache 32 | Cache-Control: 33 | - no-cache, no-store 34 | Content-Type: 35 | - application/json;charset=UTF-8 36 | Transfer-Encoding: 37 | - chunked 38 | body: 39 | encoding: ASCII-8BIT 40 | string: '{"id":"https://login.salesforce.com/id/00D1a000000H3O9EAK/0051a000000UGT8AAO","issued_at":"1435789323964","token_type":"Bearer","instance_url":"https://","signature":"vfN4LijqlTxjHAcDWDjrwczNUgVgtpNxMVcf6IT0H2Q=","access_token":"00D1a000000H3O9!AQ4AQKJZZLBvLm3vEyoa1I_FUwCVKJTQydGXcIOYHXeuNRsEB5WSOXQC2x.kIQ3wbc1V_KhljRRKg43aIkvmAoiG.a4Ll49g"}' 41 | http_version: 42 | recorded_at: Wed, 01 Jul 2015 22:22:03 GMT 43 | - request: 44 | method: post 45 | uri: https:///services/data//sobjects/CustomObject__c 46 | body: 47 | encoding: UTF-8 48 | string: '{"Name":"Sample object"}' 49 | headers: 50 | User-Agent: 51 | - Faraday v0.9.1 52 | Content-Type: 53 | - application/json 54 | Authorization: 55 | - OAuth 00D1a000000H3O9!AQ4AQKJZZLBvLm3vEyoa1I_FUwCVKJTQydGXcIOYHXeuNRsEB5WSOXQC2x.kIQ3wbc1V_KhljRRKg43aIkvmAoiG.a4Ll49g 56 | Accept-Encoding: 57 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 58 | Accept: 59 | - "*/*" 60 | response: 61 | status: 62 | code: 201 63 | message: Created 64 | headers: 65 | Date: 66 | - Wed, 01 Jul 2015 22:22:04 GMT 67 | Set-Cookie: 68 | - BrowserId=bgHJK63LRWaIlTO2E2U0QA;Path=/;Domain=.salesforce.com;Expires=Sun, 69 | 30-Aug-2015 22:22:04 GMT 70 | Expires: 71 | - Thu, 01 Jan 1970 00:00:00 GMT 72 | Sforce-Limit-Info: 73 | - api-usage=8/15000 74 | Location: 75 | - "/services/data//sobjects/CustomObject__c/a001a00000309IcAAI" 76 | Content-Type: 77 | - application/json;charset=UTF-8 78 | Transfer-Encoding: 79 | - chunked 80 | body: 81 | encoding: ASCII-8BIT 82 | string: '{"id":"a001a00000309IcAAI","success":true,"errors":[]}' 83 | http_version: 84 | recorded_at: Wed, 01 Jul 2015 22:22:05 GMT 85 | - request: 86 | method: delete 87 | uri: https:///services/data//sobjects/CustomObject__c/a001a00000309IcAAI 88 | body: 89 | encoding: US-ASCII 90 | string: '' 91 | headers: 92 | User-Agent: 93 | - Faraday v0.9.1 94 | Authorization: 95 | - OAuth 00D1a000000H3O9!AQ4AQKJZZLBvLm3vEyoa1I_FUwCVKJTQydGXcIOYHXeuNRsEB5WSOXQC2x.kIQ3wbc1V_KhljRRKg43aIkvmAoiG.a4Ll49g 96 | Accept-Encoding: 97 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 98 | Accept: 99 | - "*/*" 100 | response: 101 | status: 102 | code: 204 103 | message: No Content 104 | headers: 105 | Date: 106 | - Wed, 01 Jul 2015 22:22:06 GMT 107 | Set-Cookie: 108 | - BrowserId=bEI3cWD_SaCpOD8wvl95jg;Path=/;Domain=.salesforce.com;Expires=Sun, 109 | 30-Aug-2015 22:22:06 GMT 110 | Expires: 111 | - Thu, 01 Jan 1970 00:00:00 GMT 112 | Sforce-Limit-Info: 113 | - api-usage=7/15000 114 | body: 115 | encoding: UTF-8 116 | string: '' 117 | http_version: 118 | recorded_at: Wed, 01 Jul 2015 22:22:06 GMT 119 | recorded_with: VCR 2.9.3 120 | -------------------------------------------------------------------------------- /test/cassettes/Restforce_DB_Attacher/_run/given_a_Salesforce_record_with_an_upsert_ID/for_a_Passive_strategy/does_nothing.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https:///services/oauth2/token 6 | body: 7 | encoding: US-ASCII 8 | string: grant_type=password&client_id=&client_secret=&username=&password= 9 | headers: 10 | User-Agent: 11 | - Faraday v0.9.1 12 | Content-Type: 13 | - application/x-www-form-urlencoded 14 | Accept-Encoding: 15 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 16 | Accept: 17 | - "*/*" 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Fri, 26 Jun 2015 19:46:46 GMT 25 | Set-Cookie: 26 | - BrowserId=Gkkh-ZFNRxyIOcd93LCDOA;Path=/;Domain=.salesforce.com;Expires=Tue, 27 | 25-Aug-2015 19:46:46 GMT 28 | Expires: 29 | - Thu, 01 Jan 1970 00:00:00 GMT 30 | Pragma: 31 | - no-cache 32 | Cache-Control: 33 | - no-cache, no-store 34 | Content-Type: 35 | - application/json;charset=UTF-8 36 | Transfer-Encoding: 37 | - chunked 38 | body: 39 | encoding: ASCII-8BIT 40 | string: '{"id":"https://login.salesforce.com/id/00D1a000000H3O9EAK/0051a000000UGT8AAO","issued_at":"1435348006611","token_type":"Bearer","instance_url":"https://","signature":"hjMXzEQ9bvhWig05CcU8y1F1Q+ENQKFH2QymHnYfpYg=","access_token":"00D1a000000H3O9!AQ4AQBPmUB0jG__CiIfWEBZ.RUTVVJm_WreLssta.gEq1gtlj_BSyYQminxMfJ6g2GW08.1Chcd.coP0_KTpj3WhU_NUATWs"}' 41 | http_version: 42 | recorded_at: Fri, 26 Jun 2015 19:46:46 GMT 43 | - request: 44 | method: post 45 | uri: https:///services/data//sobjects/CustomObject__c 46 | body: 47 | encoding: UTF-8 48 | string: '{"SynchronizationID__c":"CustomObject::1"}' 49 | headers: 50 | User-Agent: 51 | - Faraday v0.9.1 52 | Content-Type: 53 | - application/json 54 | Authorization: 55 | - OAuth 00D1a000000H3O9!AQ4AQBPmUB0jG__CiIfWEBZ.RUTVVJm_WreLssta.gEq1gtlj_BSyYQminxMfJ6g2GW08.1Chcd.coP0_KTpj3WhU_NUATWs 56 | Accept-Encoding: 57 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 58 | Accept: 59 | - "*/*" 60 | response: 61 | status: 62 | code: 201 63 | message: Created 64 | headers: 65 | Date: 66 | - Fri, 26 Jun 2015 19:46:47 GMT 67 | Set-Cookie: 68 | - BrowserId=u-x2mBaxSZmrH4BwMWPgGA;Path=/;Domain=.salesforce.com;Expires=Tue, 69 | 25-Aug-2015 19:46:47 GMT 70 | Expires: 71 | - Thu, 01 Jan 1970 00:00:00 GMT 72 | Sforce-Limit-Info: 73 | - api-usage=540/15000 74 | Location: 75 | - "/services/data//sobjects/CustomObject__c/a001a000002zl5eAAA" 76 | Content-Type: 77 | - application/json;charset=UTF-8 78 | Transfer-Encoding: 79 | - chunked 80 | body: 81 | encoding: ASCII-8BIT 82 | string: '{"id":"a001a000002zl5eAAA","success":true,"errors":[]}' 83 | http_version: 84 | recorded_at: Fri, 26 Jun 2015 19:46:47 GMT 85 | - request: 86 | method: delete 87 | uri: https:///services/data//sobjects/CustomObject__c/a001a000002zl5eAAA 88 | body: 89 | encoding: US-ASCII 90 | string: '' 91 | headers: 92 | User-Agent: 93 | - Faraday v0.9.1 94 | Authorization: 95 | - OAuth 00D1a000000H3O9!AQ4AQBPmUB0jG__CiIfWEBZ.RUTVVJm_WreLssta.gEq1gtlj_BSyYQminxMfJ6g2GW08.1Chcd.coP0_KTpj3WhU_NUATWs 96 | Accept-Encoding: 97 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 98 | Accept: 99 | - "*/*" 100 | response: 101 | status: 102 | code: 204 103 | message: No Content 104 | headers: 105 | Date: 106 | - Fri, 26 Jun 2015 19:46:48 GMT 107 | Set-Cookie: 108 | - BrowserId=YGP4RqxrQcSS8KV6tgKS7g;Path=/;Domain=.salesforce.com;Expires=Tue, 109 | 25-Aug-2015 19:46:48 GMT 110 | Expires: 111 | - Thu, 01 Jan 1970 00:00:00 GMT 112 | Sforce-Limit-Info: 113 | - api-usage=541/15000 114 | body: 115 | encoding: UTF-8 116 | string: '' 117 | http_version: 118 | recorded_at: Fri, 26 Jun 2015 19:46:48 GMT 119 | recorded_with: VCR 2.9.3 120 | -------------------------------------------------------------------------------- /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: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https:///services/oauth2/token 6 | body: 7 | encoding: US-ASCII 8 | string: grant_type=password&client_id=&client_secret=&username=&password= 9 | headers: 10 | User-Agent: 11 | - Faraday v0.9.1 12 | Content-Type: 13 | - application/x-www-form-urlencoded 14 | Accept-Encoding: 15 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 16 | Accept: 17 | - "*/*" 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Tue, 30 Jun 2015 21:55:26 GMT 25 | Set-Cookie: 26 | - BrowserId=R2hPFLLfQDKPkisldnr-ig;Path=/;Domain=.salesforce.com;Expires=Sat, 27 | 29-Aug-2015 21:55:26 GMT 28 | Expires: 29 | - Thu, 01 Jan 1970 00:00:00 GMT 30 | Pragma: 31 | - no-cache 32 | Cache-Control: 33 | - no-cache, no-store 34 | Content-Type: 35 | - application/json;charset=UTF-8 36 | Transfer-Encoding: 37 | - chunked 38 | body: 39 | encoding: ASCII-8BIT 40 | string: '{"id":"https://login.salesforce.com/id/00D1a000000H3O9EAK/0051a000000UGT8AAO","issued_at":"1435701326155","token_type":"Bearer","instance_url":"https://","signature":"6qg0fqHjFmvRfj7hY8suf/HbufcFj+0igrCnL21hvok=","access_token":"00D1a000000H3O9!AQ4AQCuQcUjpJEq9lrBhPkqOA0H54X35lOGdjXK_f7u4TlJkBXW4P6Yb_svq0CVrLupjKXzW7Zx3KIj4GLQzCtt5g5Eot9U9"}' 41 | http_version: 42 | recorded_at: Tue, 30 Jun 2015 21:55:26 GMT 43 | - request: 44 | method: post 45 | uri: https:///services/data//sobjects/CustomObject__c 46 | body: 47 | encoding: UTF-8 48 | string: '{"Name":"Are you going to Scarborough Fair?","Example_Field__c":"Parsley, 49 | Sage, Rosemary, and Thyme."}' 50 | headers: 51 | User-Agent: 52 | - Faraday v0.9.1 53 | Content-Type: 54 | - application/json 55 | Authorization: 56 | - OAuth 00D1a000000H3O9!AQ4AQCuQcUjpJEq9lrBhPkqOA0H54X35lOGdjXK_f7u4TlJkBXW4P6Yb_svq0CVrLupjKXzW7Zx3KIj4GLQzCtt5g5Eot9U9 57 | Accept-Encoding: 58 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 59 | Accept: 60 | - "*/*" 61 | response: 62 | status: 63 | code: 201 64 | message: Created 65 | headers: 66 | Date: 67 | - Tue, 30 Jun 2015 21:55:27 GMT 68 | Set-Cookie: 69 | - BrowserId=Wd9It7f1QGyOe-aFc6BbGw;Path=/;Domain=.salesforce.com;Expires=Sat, 70 | 29-Aug-2015 21:55:27 GMT 71 | Expires: 72 | - Thu, 01 Jan 1970 00:00:00 GMT 73 | Sforce-Limit-Info: 74 | - api-usage=1/15000 75 | Location: 76 | - "/services/data//sobjects/CustomObject__c/a001a00000306RRAAY" 77 | Content-Type: 78 | - application/json;charset=UTF-8 79 | Transfer-Encoding: 80 | - chunked 81 | body: 82 | encoding: ASCII-8BIT 83 | string: '{"id":"a001a00000306RRAAY","success":true,"errors":[]}' 84 | http_version: 85 | recorded_at: Tue, 30 Jun 2015 21:55:27 GMT 86 | - request: 87 | method: delete 88 | uri: https:///services/data//sobjects/CustomObject__c/a001a00000306RRAAY 89 | body: 90 | encoding: US-ASCII 91 | string: '' 92 | headers: 93 | User-Agent: 94 | - Faraday v0.9.1 95 | Authorization: 96 | - OAuth 00D1a000000H3O9!AQ4AQCuQcUjpJEq9lrBhPkqOA0H54X35lOGdjXK_f7u4TlJkBXW4P6Yb_svq0CVrLupjKXzW7Zx3KIj4GLQzCtt5g5Eot9U9 97 | Accept-Encoding: 98 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 99 | Accept: 100 | - "*/*" 101 | response: 102 | status: 103 | code: 204 104 | message: No Content 105 | headers: 106 | Date: 107 | - Tue, 30 Jun 2015 21:55:28 GMT 108 | Set-Cookie: 109 | - BrowserId=UresOOfWSbqgn4mbs9k_cg;Path=/;Domain=.salesforce.com;Expires=Sat, 110 | 29-Aug-2015 21:55:28 GMT 111 | Expires: 112 | - Thu, 01 Jan 1970 00:00:00 GMT 113 | Sforce-Limit-Info: 114 | - api-usage=1/15000 115 | body: 116 | encoding: UTF-8 117 | string: '' 118 | http_version: 119 | recorded_at: Tue, 30 Jun 2015 21:55:28 GMT 120 | recorded_with: VCR 2.9.3 121 | -------------------------------------------------------------------------------- /test/cassettes/Restforce_DB_Initializer/_run/given_an_existing_Salesforce_record/for_a_Passive_strategy/does_not_create_a_database_record.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https:///services/oauth2/token 6 | body: 7 | encoding: US-ASCII 8 | string: grant_type=password&client_id=&client_secret=&username=&password= 9 | headers: 10 | User-Agent: 11 | - Faraday v0.9.1 12 | Content-Type: 13 | - application/x-www-form-urlencoded 14 | Accept-Encoding: 15 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 16 | Accept: 17 | - "*/*" 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Thu, 25 Jun 2015 21:55:10 GMT 25 | Set-Cookie: 26 | - BrowserId=pihfMgqRR4OzdfbCSxhKog;Path=/;Domain=.salesforce.com;Expires=Mon, 27 | 24-Aug-2015 21:55:10 GMT 28 | Expires: 29 | - Thu, 01 Jan 1970 00:00:00 GMT 30 | Pragma: 31 | - no-cache 32 | Cache-Control: 33 | - no-cache, no-store 34 | Content-Type: 35 | - application/json;charset=UTF-8 36 | Transfer-Encoding: 37 | - chunked 38 | body: 39 | encoding: ASCII-8BIT 40 | string: '{"id":"https://login.salesforce.com/id/00D1a000000H3O9EAK/0051a000000UGT8AAO","issued_at":"1435269310996","token_type":"Bearer","instance_url":"https://","signature":"5J8vUE7/tWMkUqUr2xPC8bYnXw+G4OjMlERazFtvin0=","access_token":"00D1a000000H3O9!AQ4AQIEnz7RMa1N2z10U8y.cU3CAaZeOCxcDVSetug6psPcDYNjSbdC91y8MHqmZ.ZXE_zkQURv2YNCWYEsl0fcbZwb1MPEA"}' 41 | http_version: 42 | recorded_at: Thu, 25 Jun 2015 21:55:11 GMT 43 | - request: 44 | method: post 45 | uri: https:///services/data//sobjects/CustomObject__c 46 | body: 47 | encoding: UTF-8 48 | string: '{"Name":"Custom object","Example_Field__c":"Some sample text"}' 49 | headers: 50 | User-Agent: 51 | - Faraday v0.9.1 52 | Content-Type: 53 | - application/json 54 | Authorization: 55 | - OAuth 00D1a000000H3O9!AQ4AQIEnz7RMa1N2z10U8y.cU3CAaZeOCxcDVSetug6psPcDYNjSbdC91y8MHqmZ.ZXE_zkQURv2YNCWYEsl0fcbZwb1MPEA 56 | Accept-Encoding: 57 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 58 | Accept: 59 | - "*/*" 60 | response: 61 | status: 62 | code: 201 63 | message: Created 64 | headers: 65 | Date: 66 | - Thu, 25 Jun 2015 21:55:11 GMT 67 | Set-Cookie: 68 | - BrowserId=m1yom3opRXWNW1J6BePm3g;Path=/;Domain=.salesforce.com;Expires=Mon, 69 | 24-Aug-2015 21:55:11 GMT 70 | Expires: 71 | - Thu, 01 Jan 1970 00:00:00 GMT 72 | Sforce-Limit-Info: 73 | - api-usage=207/15000 74 | Location: 75 | - "/services/data//sobjects/CustomObject__c/a001a000002zha8AAA" 76 | Content-Type: 77 | - application/json;charset=UTF-8 78 | Transfer-Encoding: 79 | - chunked 80 | body: 81 | encoding: ASCII-8BIT 82 | string: '{"id":"a001a000002zha8AAA","success":true,"errors":[]}' 83 | http_version: 84 | recorded_at: Thu, 25 Jun 2015 21:55:11 GMT 85 | - request: 86 | method: delete 87 | uri: https:///services/data//sobjects/CustomObject__c/a001a000002zha8AAA 88 | body: 89 | encoding: US-ASCII 90 | string: '' 91 | headers: 92 | User-Agent: 93 | - Faraday v0.9.1 94 | Authorization: 95 | - OAuth 00D1a000000H3O9!AQ4AQIEnz7RMa1N2z10U8y.cU3CAaZeOCxcDVSetug6psPcDYNjSbdC91y8MHqmZ.ZXE_zkQURv2YNCWYEsl0fcbZwb1MPEA 96 | Accept-Encoding: 97 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 98 | Accept: 99 | - "*/*" 100 | response: 101 | status: 102 | code: 204 103 | message: No Content 104 | headers: 105 | Date: 106 | - Thu, 25 Jun 2015 21:55:11 GMT 107 | Set-Cookie: 108 | - BrowserId=SfLL8HEyTvemeGBmJP7Qmg;Path=/;Domain=.salesforce.com;Expires=Mon, 109 | 24-Aug-2015 21:55:11 GMT 110 | Expires: 111 | - Thu, 01 Jan 1970 00:00:00 GMT 112 | Sforce-Limit-Info: 113 | - api-usage=208/15000 114 | body: 115 | encoding: UTF-8 116 | string: '' 117 | http_version: 118 | recorded_at: Thu, 25 Jun 2015 21:55:12 GMT 119 | recorded_with: VCR 2.9.3 120 | -------------------------------------------------------------------------------- /test/lib/forked_process_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | describe ForkedProcess do 4 | 5 | describe "running a forked process" do 6 | let(:process) do 7 | ForkedProcess.new.tap do |forked| 8 | forked.write { |writer| writer.write("Hello!") } 9 | end 10 | end 11 | 12 | describe "#run" do 13 | 14 | it "synchronizes the `write` block's output into a `read` block" do 15 | value = nil 16 | 17 | process.read { |reader| value = reader.read } 18 | process.run 19 | 20 | expect(value).to_equal("Hello!") 21 | end 22 | 23 | describe "when the write block exits unsuccessfully due to an error" do 24 | let(:process) do 25 | ForkedProcess.new.tap do |forked| 26 | forked.write { |_| raise "Whoops!" } 27 | forked.read { |_| nil } 28 | end 29 | end 30 | 31 | it "raises an UnsuccessfulExit exception" do 32 | expect { silence_stream(STDERR) { process.run } }.to_raise( 33 | ForkedProcess::UnsuccessfulExit, 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/lib/restforce/db/accumulator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Accumulator do 4 | 5 | configure! 6 | 7 | let(:accumulator) { Restforce::DB::Accumulator.new } 8 | 9 | describe "#store" do 10 | let(:timestamp) { Time.now } 11 | let(:changes) { { one_fish: "Two Fish", red_fish: "Blue Fish" } } 12 | 13 | before do 14 | accumulator.store(timestamp, changes) 15 | end 16 | 17 | it "stores the passed changeset in the accumulator" do 18 | expect(accumulator[timestamp]).to_equal changes 19 | end 20 | 21 | describe "for a pre-existing timestamp" do 22 | let(:new_changes) { { old_fish: "New Fish" } } 23 | 24 | before do 25 | accumulator.store(timestamp, new_changes) 26 | end 27 | 28 | it "updates the existing changeset in the accumulator" do 29 | expect(accumulator[timestamp]).to_equal changes.merge(new_changes) 30 | end 31 | end 32 | end 33 | 34 | describe "#attributes" do 35 | let(:timestamp) { Time.now } 36 | let(:changes) { { one_fish: "Two Fish", red_fish: "Blue Fish" } } 37 | 38 | before do 39 | accumulator.store(timestamp, changes) 40 | end 41 | 42 | it "returns the attributes from the recorded changeset" do 43 | expect(accumulator.attributes).to_equal changes 44 | end 45 | 46 | it "combines multiple changesets" do 47 | additional_changes = { hootie: "Blow Fish" } 48 | 49 | accumulator.store Time.now, additional_changes 50 | expect(accumulator.attributes).to_equal changes.merge(additional_changes) 51 | end 52 | 53 | describe "with conflicting changesets" do 54 | let(:new_changes) { { one_fish: "No Fish", red_fish: "Glow Fish" } } 55 | 56 | it "respects the most recently updated values" do 57 | accumulator.store timestamp + 1, new_changes 58 | expect(accumulator.attributes).to_equal new_changes 59 | end 60 | 61 | it "ignores less recently updated values" do 62 | accumulator.store timestamp - 1, new_changes 63 | expect(accumulator.attributes).to_equal changes 64 | end 65 | end 66 | end 67 | 68 | describe "#current" do 69 | let(:attributes) { { wocket: "Pocket", jertain: "Curtain", zelf: "Shelf" } } 70 | 71 | before do 72 | accumulator.store(Time.now, attributes) 73 | end 74 | 75 | it "returns the current values for all attributes in the passed hash" do 76 | expect(accumulator.current(wocket: "Locket")).to_equal(wocket: "Pocket") 77 | expect(accumulator.current(jertain: "Curtain", zelf: "Belfrey")).to_equal( 78 | jertain: "Curtain", 79 | zelf: "Shelf", 80 | ) 81 | end 82 | end 83 | 84 | describe "#changed?" do 85 | let(:attributes) { { wocket: "Pocket", jertain: "Curtain", zelf: "Shelf" } } 86 | 87 | before do 88 | accumulator.store(Time.now, attributes) 89 | end 90 | 91 | it "returns true if there are changes in the passed attributes hash" do 92 | expect(accumulator).to_be :changed?, wocket: "Locket", zelf: "Shelf" 93 | end 94 | 95 | it "ignores attributes not set in both the Accumulator and the passed Hash" do 96 | expect(accumulator).to_not_be :changed?, yottle: "Bottle" 97 | end 98 | end 99 | 100 | describe "#up_to_date_for?" do 101 | let(:timestamp) { Time.now } 102 | 103 | before do 104 | accumulator.store(timestamp, some: "set", of: "attributes") 105 | end 106 | 107 | it "returns true if the passed timestamp is less recent than the stored time" do 108 | expect(accumulator).to_be :up_to_date_for?, timestamp - 1 109 | end 110 | 111 | it "returns true if the passed timestamp is identical to the stored time" do 112 | expect(accumulator).to_be :up_to_date_for?, timestamp 113 | end 114 | 115 | it "returns false if the passed timestamp is more recent than the stored time" do 116 | expect(accumulator).to_not_be :up_to_date_for?, timestamp + 1 117 | end 118 | end 119 | 120 | end 121 | -------------------------------------------------------------------------------- /test/lib/restforce/db/adapter_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Adapter do 4 | 5 | configure! 6 | 7 | let(:adapter) { Restforce::DB::Adapter.new } 8 | let(:attributes) { { where: "Here", when: Time.now } } 9 | 10 | describe "#to_database" do 11 | let(:results) { adapter.to_database(attributes) } 12 | 13 | it "returns the passed attributes, unchanged" do 14 | expect(results).to_equal attributes 15 | end 16 | end 17 | 18 | describe "#from_database" do 19 | let(:results) { adapter.from_database(attributes) } 20 | 21 | it "converts times to ISO-8601 timestamps" do 22 | expect(results[:when]).to_equal attributes[:when].utc.iso8601 23 | end 24 | 25 | it "leaves other attributes unchanged" do 26 | expect(results[:where]).to_equal attributes[:where] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/lib/restforce/db/association_cache_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::AssociationCache do 4 | 5 | configure! 6 | 7 | let(:cache) { Restforce::DB::AssociationCache.new } 8 | let(:database_model) { CustomObject } 9 | let(:lookups) { { salesforce_id: "a001a000001E1vREAL" } } 10 | let(:record) { database_model.new(lookups) } 11 | 12 | describe "#initialize" do 13 | let(:cache) { Restforce::DB::AssociationCache.new(record) } 14 | 15 | it "caches the passed record when present" do 16 | expect(cache.cache[database_model]).to_equal [record] 17 | end 18 | end 19 | 20 | describe "#<<" do 21 | before do 22 | cache << record 23 | end 24 | 25 | it "caches the appended record by class" do 26 | expect(cache.cache[database_model]).to_equal [record] 27 | end 28 | end 29 | 30 | describe "#find" do 31 | let(:found) { cache.find(database_model, lookups) } 32 | 33 | describe "when the record has been added to the cache" do 34 | before do 35 | cache << record 36 | end 37 | 38 | it "finds the record" do 39 | expect(found).to_equal record 40 | end 41 | end 42 | 43 | describe "when the record has been persisted" do 44 | before do 45 | record.save! 46 | end 47 | 48 | it "finds the persisted record by its lookups" do 49 | expect(found).to_equal record 50 | end 51 | end 52 | 53 | describe "when a record of another class has been added with the same lookups" do 54 | before do 55 | cache << User.new(lookups) 56 | end 57 | 58 | it "returns nil" do 59 | expect(found).to_be_nil 60 | end 61 | end 62 | 63 | describe "when no record has been added or persisted" do 64 | 65 | it "returns nil" do 66 | expect(found).to_be_nil 67 | end 68 | end 69 | end 70 | 71 | end 72 | -------------------------------------------------------------------------------- /test/lib/restforce/db/associator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Associator do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:associator) { Restforce::DB::Associator.new(mapping) } 9 | 10 | describe "#run", :vcr do 11 | 12 | describe "given a BelongsTo association" do 13 | let(:inverse_mapping) do 14 | Restforce::DB::Mapping.new(User, "Contact").tap do |map| 15 | map.fields = { email: "Email" } 16 | map.associations << Restforce::DB::Associations::HasOne.new( 17 | :custom_object, 18 | through: "Friend__c", 19 | ) 20 | end 21 | end 22 | let(:user_salesforce_id) do 23 | Salesforce.create!( 24 | inverse_mapping.salesforce_model, 25 | "Email" => "somebody@example.com", 26 | "LastName" => "Somebody", 27 | ) 28 | end 29 | let(:object_salesforce_id) do 30 | Salesforce.create!(mapping.salesforce_model, "Friend__c" => user_salesforce_id) 31 | end 32 | let(:association) { Restforce::DB::Associations::BelongsTo.new(:user, through: "Friend__c") } 33 | let(:user) { inverse_mapping.database_model.create!(salesforce_id: user_salesforce_id) } 34 | let(:object) { mapping.database_model.create!(user: user, salesforce_id: object_salesforce_id) } 35 | 36 | before do 37 | Restforce::DB::Registry << mapping 38 | Restforce::DB::Registry << inverse_mapping 39 | mapping.associations << association 40 | end 41 | 42 | describe "when the association lookup is through Id" do 43 | let(:association) { Restforce::DB::Associations::BelongsTo.new(:user, through: "Id") } 44 | 45 | it "ignores the association" do 46 | expect(associator.send(:belongs_to_associations)).to_be :empty? 47 | end 48 | end 49 | 50 | describe "given another record for association" do 51 | let(:new_user_salesforce_id) do 52 | Salesforce.create!( 53 | inverse_mapping.salesforce_model, 54 | "Email" => "somebody+else@example.com", 55 | "LastName" => "Somebody", 56 | ) 57 | end 58 | let(:new_user) { inverse_mapping.database_model.create!(salesforce_id: new_user_salesforce_id) } 59 | let(:salesforce_instance) { mapping.salesforce_record_type.find(object_salesforce_id) } 60 | 61 | describe "when the Salesforce association is out of date" do 62 | before do 63 | object.update!(user: new_user) 64 | end 65 | 66 | it "updates the association ID in Salesforce" do 67 | associator.run 68 | expect(salesforce_instance.record["Friend__c"]).to_equal new_user_salesforce_id 69 | end 70 | end 71 | 72 | describe "when the database association is out of date" do 73 | before do 74 | object && new_user 75 | salesforce_instance.update! "Friend__c" => new_user_salesforce_id 76 | end 77 | 78 | it "updates the associated record in the database" do 79 | # We stub `last_update` to get around issues with VCR's cached 80 | # timestamp; we need the Salesforce record to be more recent. 81 | Restforce::DB::Instances::Salesforce.stub_any_instance(:last_update, Time.now) do 82 | associator.run 83 | end 84 | expect(object.reload.user).to_equal new_user 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/lib/restforce/db/attacher_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Attacher do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:attacher) { Restforce::DB::Attacher.new(mapping) } 9 | 10 | describe "#run", vcr: { match_requests_on: [:method, VCR.request_matchers.uri_without_param(:q)] } do 11 | let(:attributes) do 12 | { 13 | "SynchronizationId__c" => "CustomObject::#{database_record.id}", 14 | } 15 | end 16 | let(:database_record) { database_model.create! } 17 | let(:salesforce_id) { Salesforce.create!(salesforce_model, attributes) } 18 | 19 | describe "given a Salesforce record with an upsert ID" do 20 | before do 21 | salesforce_id 22 | end 23 | 24 | describe "for a Passive strategy" do 25 | before do 26 | mapping.strategy = Restforce::DB::Strategies::Passive.new 27 | attacher.run 28 | end 29 | 30 | it "does nothing" do 31 | expect(database_record.reload).to_not_be :salesforce_id? 32 | end 33 | end 34 | 35 | describe "for an Always strategy" do 36 | before do 37 | mapping.strategy = Restforce::DB::Strategies::Always.new 38 | attacher.run 39 | end 40 | 41 | it "links the Salesforce record to the matching database record" do 42 | expect(database_record.reload).to_be :salesforce_id? 43 | end 44 | 45 | it "wipes the SynchronizationId__c" do 46 | salesforce_record = mapping.salesforce_record_type.find(salesforce_id).record 47 | expect(salesforce_record.SynchronizationId__c).to_be_nil 48 | end 49 | 50 | describe "when the matching database record has a salesforce_id" do 51 | let(:old_id) { "a001a000001E1vFAKE" } 52 | let(:database_record) { database_model.create!(salesforce_id: old_id) } 53 | 54 | it "does not change the current Salesforce ID" do 55 | expect(database_record.reload.salesforce_id).to_equal old_id 56 | end 57 | 58 | it "wipes the SynchronizationId__c" do 59 | salesforce_record = mapping.salesforce_record_type.find(salesforce_id).record 60 | expect(salesforce_record.SynchronizationId__c).to_be_nil 61 | end 62 | end 63 | 64 | describe "when no matching database record can be found" do 65 | let(:database_record) { nil } 66 | let(:attributes) do 67 | { 68 | "SynchronizationId__c" => "CustomObject::1", 69 | } 70 | end 71 | 72 | it "wipes the SynchronizationId__c" do 73 | salesforce_record = mapping.salesforce_record_type.find(salesforce_id).record 74 | expect(salesforce_record.SynchronizationId__c).to_be_nil 75 | end 76 | end 77 | 78 | describe "when the upsert ID is for another database model" do 79 | let(:attributes) do 80 | { 81 | "SynchronizationId__c" => "User::1", 82 | } 83 | end 84 | 85 | it "does not wipe the SynchronizationId__c" do 86 | salesforce_record = mapping.salesforce_record_type.find(salesforce_id).record 87 | expect(salesforce_record.SynchronizationId__c).to_not_be_nil 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/lib/restforce/db/attribute_map_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::AttributeMap do 4 | 5 | configure! 6 | 7 | let(:database_model) { CustomObject } 8 | let(:salesforce_model) { "CustomObject__c" } 9 | let(:fields) do 10 | { 11 | column_one: "SF_Field_One__c", 12 | column_two: "SF_Field_Two__c", 13 | } 14 | end 15 | let(:adapter) { Restforce::DB::Adapter.new } 16 | let(:attribute_map) { Restforce::DB::AttributeMap.new(database_model, salesforce_model, fields, adapter) } 17 | 18 | describe "#attributes" do 19 | let(:mapping) do 20 | Restforce::DB::Mapping.new(database_model, salesforce_model).tap do |map| 21 | map.fields = fields 22 | end 23 | end 24 | 25 | it "builds a normalized Hash of database attribute values" do 26 | record = Hashie::Mash.new(column_one: "Eenie", column_two: "Meenie") 27 | attributes = attribute_map.attributes(database_model, record) 28 | 29 | expect(attributes.keys).to_equal(mapping.salesforce_fields) 30 | expect(attributes.values).to_equal(%w(Eenie Meenie)) 31 | end 32 | 33 | it "builds a normalized Hash of Salesforce field values" do 34 | record = Hashie::Mash.new("SF_Field_One__c" => "Minie", "SF_Field_Two__c" => "Moe") 35 | attributes = attribute_map.attributes(salesforce_model, record) 36 | 37 | expect(attributes.keys).to_equal(mapping.salesforce_fields) 38 | expect(attributes.values).to_equal(%w(Minie Moe)) 39 | end 40 | end 41 | 42 | describe "#convert" do 43 | let(:attributes) { { "SF_Field_One__c" => "some value" } } 44 | 45 | it "converts an attribute Hash to a Salesforce-compatible form" do 46 | expect(attribute_map.convert(salesforce_model, attributes)).to_equal(attributes) 47 | end 48 | 49 | it "converts an attribute Hash to a database-compatible form" do 50 | expect(attribute_map.convert(database_model, attributes)).to_equal( 51 | fields.key(attributes.keys.first) => attributes.values.first, 52 | ) 53 | end 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /test/lib/restforce/db/attribute_maps/database_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::AttributeMaps::Database do 4 | 5 | configure! 6 | 7 | let(:adapter) { Restforce::DB::Adapter.new } 8 | let(:attribute_map) { Restforce::DB::AttributeMaps::Database.new(fields, adapter) } 9 | let(:fields) do 10 | { 11 | column_one: "SF_Field_One__c", 12 | column_two: "SF_Field_Two__c", 13 | } 14 | end 15 | 16 | describe "#attributes" do 17 | 18 | it "builds a normalized Hash of database attribute values" do 19 | record = Hashie::Mash.new(column_one: "Winkin", column_two: "Blinkin") 20 | attributes = attribute_map.attributes(record) 21 | 22 | expect(attributes.keys).to_equal(fields.values) 23 | expect(attributes.values).to_equal(%w(Winkin Blinkin)) 24 | end 25 | end 26 | 27 | describe "#convert" do 28 | let(:attributes) { { "SF_Field_One__c" => "some value" } } 29 | 30 | it "converts an attribute Hash to a database-compatible form" do 31 | expect(attribute_map.convert(attributes)).to_equal( 32 | fields.key(attributes.keys.first) => attributes.values.first, 33 | ) 34 | end 35 | 36 | describe "when an adapter has been specified" do 37 | let(:adapter) { boolean_adapter } 38 | 39 | it "uses the adapter to convert attributes to a database-compatible form" do 40 | expect(attribute_map.convert("SF_Field_One__c" => "Yes")).to_equal( 41 | column_one: true, 42 | ) 43 | expect(attribute_map.convert("SF_Field_One__c" => "No")).to_equal( 44 | column_one: false, 45 | ) 46 | end 47 | 48 | end 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /test/lib/restforce/db/attribute_maps/salesforce_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::AttributeMaps::Salesforce do 4 | 5 | configure! 6 | 7 | let(:attribute_map) { Restforce::DB::AttributeMaps::Salesforce.new(fields) } 8 | let(:fields) do 9 | { 10 | column_one: "SF_Field_One__c", 11 | column_two: "SF_Field_Two__c", 12 | } 13 | end 14 | 15 | describe "#attributes" do 16 | 17 | it "builds a normalized Hash of Salesforce attribute values" do 18 | record = Hashie::Mash.new("SF_Field_One__c" => "Winkin", "SF_Field_Two__c" => "Blinkin") 19 | attributes = attribute_map.attributes(record) 20 | 21 | expect(attributes.keys).to_equal(fields.values) 22 | expect(attributes.values).to_equal(%w(Winkin Blinkin)) 23 | end 24 | 25 | describe "for a mapping requiring Salesforce association traversal" do 26 | let(:fields) do 27 | { 28 | name: "Name", 29 | friend_name: "Friend__r.Name", 30 | } 31 | end 32 | 33 | it "builds a flattened normalized Hash of Salesforce attribute values" do 34 | record = Hashie::Mash.new( 35 | "Name" => "Turner", 36 | "Friend__r" => { "Name" => "Hooch" }, 37 | ) 38 | attributes = attribute_map.attributes(record) 39 | 40 | expect(attributes.keys).to_equal(fields.values) 41 | expect(attributes.values).to_equal(%w(Turner Hooch)) 42 | end 43 | end 44 | end 45 | 46 | describe "#convert" do 47 | let(:attributes) { { "SF_Field_One__c" => "some value" } } 48 | 49 | it "doesn't perform any special modification of the passed attribute Hash" do 50 | expect(attribute_map.convert(attributes)).to_equal(attributes) 51 | end 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /test/lib/restforce/db/cleaner_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Cleaner do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:cleaner) { Restforce::DB::Cleaner.new(mapping) } 9 | 10 | describe "#run", vcr: { match_requests_on: [:method, VCR.request_matchers.uri_without_param(:q)] } do 11 | let(:attributes) do 12 | { 13 | "Name" => "Are you going to Scarborough Fair?", 14 | "Example_Field__c" => "Parsley, Sage, Rosemary, and Thyme.", 15 | } 16 | end 17 | let(:salesforce_id) { Salesforce.create!(salesforce_model, attributes) } 18 | 19 | before do 20 | Restforce::DB::Registry << mapping 21 | end 22 | 23 | describe "given a synchronized Salesforce record" do 24 | before do 25 | database_model.create!(salesforce_id: salesforce_id) 26 | end 27 | 28 | describe "when the mapping has no conditions" do 29 | before do 30 | cleaner.run 31 | end 32 | 33 | it "does not drop the synchronized database record" do 34 | expect(database_model.last).to_not_be_nil 35 | end 36 | end 37 | 38 | describe "when the record meets the mapping conditions" do 39 | before do 40 | mapping.conditions = ["Name = '#{attributes['Name']}'"] 41 | cleaner.run 42 | end 43 | 44 | it "does not drop the synchronized database record" do 45 | expect(database_model.last).to_not_be_nil 46 | end 47 | end 48 | 49 | describe "when the record does not meet the mapping conditions" do 50 | before do 51 | mapping.conditions = ["Name != '#{attributes['Name']}'"] 52 | end 53 | 54 | it "drops the synchronized database record" do 55 | cleaner.run 56 | expect(database_model.last).to_be_nil 57 | end 58 | 59 | describe "but meets conditions for a parallel mapping" do 60 | let(:parallel_mapping) do 61 | Restforce::DB::Mapping.new(database_model, salesforce_model).tap do |map| 62 | map.conditions = ["Name = '#{attributes['Name']}'"] 63 | end 64 | end 65 | 66 | before do 67 | Restforce::DB::Registry << parallel_mapping 68 | end 69 | 70 | it "does not drop the synchronized database record" do 71 | cleaner.run 72 | expect(database_model.last).to_not_be_nil 73 | end 74 | end 75 | end 76 | 77 | describe "when the record has been deleted in Salesforce" do 78 | let(:runner) { Restforce::DB::Runner.new(0, Time.now - 300) } 79 | let(:cleaner) { Restforce::DB::Cleaner.new(mapping, runner) } 80 | let(:dummy_response) do 81 | [ 82 | Restforce::Mash.new(id: salesforce_id), 83 | ] 84 | end 85 | 86 | before do 87 | runner.tick! 88 | end 89 | 90 | it "drops the synchronized database record" do 91 | Restforce::DB::Client.stub_any_instance(:get_deleted_between, dummy_response) do 92 | cleaner.run 93 | end 94 | 95 | expect(database_model.last).to_be_nil 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/lib/restforce/db/collector_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Collector do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:collector) { Restforce::DB::Collector.new(mapping) } 9 | 10 | describe "#run", vcr: { match_requests_on: [:method, VCR.request_matchers.uri_without_param(:q)] } do 11 | let(:attributes) do 12 | { 13 | "Name" => "Custom object", 14 | "Example_Field__c" => "Some sample text", 15 | } 16 | end 17 | let(:salesforce_id) { Salesforce.create!(salesforce_model, attributes) } 18 | let(:key) { [salesforce_id, salesforce_model] } 19 | 20 | subject { collector.run } 21 | 22 | describe "given an existing Salesforce record" do 23 | before { salesforce_id } 24 | 25 | describe "which has not been synchronized" do 26 | 27 | it "does not store any attributes" do 28 | expect(subject[key]).to_be :empty? 29 | end 30 | end 31 | 32 | describe "which has been synchronized" do 33 | let(:database_metadata) { { salesforce_id: salesforce_id, synchronized_at: Time.now + 1 } } 34 | let(:database_record) do 35 | database_attributes = mapping.convert(database_model, attributes) 36 | database_model.create!(database_attributes.merge(database_metadata)) 37 | end 38 | 39 | before { database_record } 40 | 41 | it "returns the attributes from the Salesforce record" do 42 | record = mapping.salesforce_record_type.find(salesforce_id) 43 | 44 | expect(subject[key]).to_equal( 45 | record.last_update => { 46 | "Name" => attributes["Name"], 47 | "Example_Field__c" => attributes["Example_Field__c"], 48 | }, 49 | ) 50 | end 51 | end 52 | end 53 | 54 | describe "given an existing database record" do 55 | let(:salesforce_id) { "a001a000001E1vREAL" } 56 | let(:database_metadata) { { salesforce_id: salesforce_id, synchronized_at: Time.now } } 57 | let(:database_record) do 58 | database_attributes = mapping.convert(database_model, attributes) 59 | database_model.create!(database_attributes.merge(database_metadata)) 60 | end 61 | 62 | before { database_record } 63 | 64 | it "returns the attributes from the database record" do 65 | record = mapping.database_record_type.find(salesforce_id) 66 | 67 | expect(subject[key]).to_equal( 68 | record.last_update => { 69 | "Name" => attributes["Name"], 70 | "Example_Field__c" => attributes["Example_Field__c"], 71 | }, 72 | ) 73 | end 74 | end 75 | 76 | describe "given a Salesforce record with an associated database record" do 77 | let(:database_attributes) do 78 | { 79 | name: "Some existing name", 80 | example: "Some existing sample text", 81 | } 82 | end 83 | let(:database_metadata) { { salesforce_id: salesforce_id, synchronized_at: Time.now } } 84 | let(:database_record) { database_model.create!(database_attributes.merge(database_metadata)) } 85 | 86 | before { database_record } 87 | 88 | it "returns the attributes from both records" do 89 | sf_record = mapping.salesforce_record_type.find(salesforce_id) 90 | db_record = mapping.database_record_type.find(salesforce_id) 91 | 92 | expect(subject[key]).to_equal( 93 | sf_record.last_update => { 94 | "Name" => attributes["Name"], 95 | "Example_Field__c" => attributes["Example_Field__c"], 96 | }, 97 | db_record.last_update => { 98 | "Name" => database_attributes[:name], 99 | "Example_Field__c" => database_attributes[:example], 100 | }, 101 | ) 102 | end 103 | end 104 | 105 | describe "when the record has not been updated outside of the system" do 106 | subject do 107 | Restforce::DB::Runner.stub_any_instance(:changed?, false) do 108 | collector.run 109 | end 110 | end 111 | 112 | before { salesforce_id } 113 | 114 | it "does not collect any changes" do 115 | expect(subject[key]).to_be :empty? 116 | end 117 | end 118 | 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/lib/restforce/db/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Configuration do 4 | 5 | configure! 6 | 7 | let(:secrets) { Secrets["client"] } 8 | let(:secrets_file) { File.expand_path("../../../../config/secrets.yml", __FILE__) } 9 | let(:configuration) { Restforce::DB::Configuration.new } 10 | 11 | describe "#before" do 12 | after do 13 | Thread.current[:before_hook] = nil 14 | end 15 | 16 | it "does nothing if invoked without a block" do 17 | # NOTE: We're asserting that this invocation doesn't raise an error. 18 | configuration.before 19 | end 20 | 21 | it "does not invoke the hook on configuration" do 22 | a = 1 23 | 24 | configuration.before { a += 1 } 25 | 26 | expect(a).to_equal 1 27 | end 28 | 29 | it "invokes the configured hook when called without a block" do 30 | a = 1 31 | 32 | configuration.before { a += 1 } 33 | configuration.before 34 | 35 | expect(a).to_equal 2 36 | end 37 | 38 | it "invokes the configured hook with passed arguments" do 39 | a = 1 40 | 41 | configuration.before { |b| a += b } 42 | configuration.before(2) 43 | 44 | expect(a).to_equal 3 45 | end 46 | end 47 | 48 | describe "#logger" do 49 | 50 | it "defaults to a null logger" do 51 | log_device = configuration.logger.instance_variable_get("@logdev") 52 | expect(log_device.dev.path).to_equal "/dev/null" 53 | end 54 | end 55 | 56 | describe "#load" do 57 | 58 | describe "when all configurations are supplied" do 59 | before do 60 | configuration.load(secrets) 61 | end 62 | 63 | it "loads the credentials from a passed hash" do 64 | expect(configuration.username).to_equal secrets["username"] 65 | expect(configuration.password).to_equal secrets["password"] 66 | expect(configuration.security_token).to_equal secrets["security_token"] 67 | expect(configuration.client_id).to_equal secrets["client_id"] 68 | expect(configuration.client_secret).to_equal secrets["client_secret"] 69 | expect(configuration.host).to_equal secrets["host"] 70 | end 71 | 72 | it "defaults the API version to DEFAULT_API_VERSION" do 73 | expect(configuration.api_version).to_equal Restforce::DB::Configuration::DEFAULT_API_VERSION 74 | end 75 | 76 | it "defaults the timeout to DEFAULT_TIMEOUT" do 77 | expect(configuration.timeout).to_equal Restforce::DB::Configuration::DEFAULT_TIMEOUT 78 | end 79 | end 80 | 81 | describe "when the loaded configuration is missing one or more keys" do 82 | let(:secrets) { {} } 83 | 84 | it "raises an error" do 85 | expect(-> { configuration.load(secrets) }).to_raise(ArgumentError) 86 | end 87 | end 88 | end 89 | 90 | describe "#parse" do 91 | before do 92 | configuration.parse(secrets_file) 93 | end 94 | 95 | it "loads the credentials from a YAML file" do 96 | expect(configuration.username).to_equal secrets["username"] 97 | expect(configuration.password).to_equal secrets["password"] 98 | expect(configuration.security_token).to_equal secrets["security_token"] 99 | expect(configuration.client_id).to_equal secrets["client_id"] 100 | expect(configuration.client_secret).to_equal secrets["client_secret"] 101 | expect(configuration.host).to_equal secrets["host"] 102 | end 103 | end 104 | 105 | end 106 | -------------------------------------------------------------------------------- /test/lib/restforce/db/dsl_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::DSL do 4 | 5 | configure! 6 | 7 | let(:database_model) { CustomObject } 8 | let(:salesforce_model) { "CustomObject__c" } 9 | let(:strategy) { :always } 10 | let(:dsl) { Restforce::DB::DSL.new(database_model, salesforce_model, strategy) } 11 | let(:mapping) { dsl.mapping } 12 | 13 | before { dsl } 14 | 15 | describe "#initialize" do 16 | 17 | it "registers a mapping for the passed models" do 18 | expect(Restforce::DB::Registry[database_model]).to_equal [mapping] 19 | expect(Restforce::DB::Registry[salesforce_model]).to_equal [mapping] 20 | end 21 | 22 | describe "when a strategy of :always is specified" do 23 | let(:strategy) { :always } 24 | 25 | it "respects the declared strategy" do 26 | expect(dsl.mapping.strategy).to_be_instance_of(Restforce::DB::Strategies::Always) 27 | end 28 | end 29 | 30 | describe "when a strategy of :passive is specified" do 31 | let(:strategy) { :passive } 32 | 33 | it "respects the declared strategy" do 34 | expect(dsl.mapping.strategy).to_be_instance_of(Restforce::DB::Strategies::Passive) 35 | end 36 | end 37 | end 38 | 39 | describe "#where" do 40 | let(:conditions) { %w(some list of query conditions) } 41 | 42 | before do 43 | dsl.where(*conditions) 44 | end 45 | 46 | it "sets a list of conditions for the mapping" do 47 | expect(mapping.conditions).to_equal conditions 48 | end 49 | end 50 | 51 | describe "#belongs_to" do 52 | before do 53 | dsl.belongs_to :some_association, through: "Some_Field__c" 54 | end 55 | 56 | it "adds an association to the created mapping" do 57 | association = mapping.associations.first 58 | expect(association).to_be_instance_of Restforce::DB::Associations::BelongsTo 59 | expect(association.name).to_equal :some_association 60 | expect(association.lookup).to_equal "Some_Field__c" 61 | end 62 | end 63 | 64 | describe "#has_one" do 65 | before do 66 | dsl.has_one :other_association, through: "Other_Field__c" 67 | end 68 | 69 | it "adds an association to the created mapping" do 70 | association = mapping.associations.first 71 | expect(association).to_be_instance_of Restforce::DB::Associations::HasOne 72 | expect(association.name).to_equal :other_association 73 | expect(association.lookup).to_equal "Other_Field__c" 74 | end 75 | end 76 | 77 | describe "#has_many" do 78 | before do 79 | dsl.has_many :multiple_associations, through: "External_Field__c" 80 | end 81 | 82 | it "adds an association to the created mapping" do 83 | association = mapping.associations.first 84 | expect(association).to_be_instance_of Restforce::DB::Associations::HasMany 85 | expect(association.name).to_equal :multiple_associations 86 | expect(association.lookup).to_equal "External_Field__c" 87 | end 88 | end 89 | 90 | describe "#maps" do 91 | let(:fields) { { some: "Fields__c", to: "Sync__c" } } 92 | 93 | before do 94 | dsl.maps fields 95 | end 96 | 97 | it "sets the fields for the created mapping" do 98 | expect(mapping.fields).to_equal fields 99 | end 100 | end 101 | 102 | describe "#converts_with" do 103 | let(:adapter) { boolean_adapter } 104 | 105 | it "sets the conversions for the created mapping" do 106 | dsl.converts_with adapter 107 | expect(mapping.adapter).to_equal adapter 108 | end 109 | 110 | describe "when the adapter does not define the expected methods" do 111 | let(:adapter) { Object.new } 112 | 113 | it "raises an ArgumentError" do 114 | expect(-> { dsl.converts_with adapter }).to_raise ArgumentError 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/lib/restforce/db/field_processor_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::FieldProcessor do 4 | 5 | configure! 6 | 7 | let(:processor) { Restforce::DB::FieldProcessor.new } 8 | let(:dummy_client) do 9 | Object.new.tap do |client| 10 | 11 | def client.describe(_) 12 | raise "This has already been invoked!" if @already_run 13 | @already_run = true 14 | 15 | Struct.new(:fields).new([ 16 | { "name" => "Createable", "createable" => true, "updateable" => false }, 17 | { "name" => "Updateable", "createable" => false, "updateable" => true }, 18 | { "name" => "Both", "createable" => true, "updateable" => true }, 19 | { "name" => "Neither", "createable" => false, "updateable" => false }, 20 | ]) 21 | end 22 | 23 | end 24 | end 25 | 26 | describe "#available_fields" do 27 | let(:fields) do 28 | %w( 29 | Createable 30 | Updateable 31 | Both 32 | Neither 33 | Relationship__r.Relateable 34 | ) 35 | end 36 | 37 | it "filters the passed fields to only existing fields for an object" do 38 | Restforce::DB.stub(:client, dummy_client) do 39 | excessive_fields = fields + ["NonExistent"] 40 | expect(processor.available_fields("CustomObject__c", excessive_fields)).to_equal(fields) 41 | end 42 | end 43 | 44 | it "filters the passed fields to only createable fields" do 45 | Restforce::DB.stub(:client, dummy_client) do 46 | expect(processor.available_fields("CustomObject__c", fields, :create)).to_equal(%w( 47 | Createable 48 | Both 49 | )) 50 | end 51 | end 52 | 53 | it "filters the passed fields to only updateable fields" do 54 | Restforce::DB.stub(:client, dummy_client) do 55 | expect(processor.available_fields("CustomObject__c", fields, :update)).to_equal(%w( 56 | Updateable 57 | Both 58 | )) 59 | end 60 | end 61 | end 62 | 63 | describe "#process" do 64 | let(:attributes) do 65 | { 66 | "Createable" => "This field is create-only!", 67 | "Updateable" => "And... this field is update-only!", 68 | "Both" => "But... this field allows both!", 69 | "Neither" => "Unfortunately, this field allows neither.", 70 | } 71 | end 72 | 73 | it "removes the non-creatable fields from the passed attribute Hash on :create" do 74 | Restforce::DB.stub(:client, dummy_client) do 75 | expect(processor.process("CustomObject__c", attributes, :create)).to_equal( 76 | "Createable" => attributes["Createable"], 77 | "Both" => attributes["Both"], 78 | ) 79 | end 80 | end 81 | 82 | it "removes the non-updateable fields from the passed attribute Hash on :update" do 83 | Restforce::DB.stub(:client, dummy_client) do 84 | expect(processor.process("CustomObject__c", attributes, :update)).to_equal( 85 | "Updateable" => attributes["Updateable"], 86 | "Both" => attributes["Both"], 87 | ) 88 | end 89 | end 90 | 91 | it "invokes the client only once for a single SObject Type" do 92 | Restforce::DB.stub(:client, dummy_client) do 93 | processor.process("CustomObject__c", attributes, :update) 94 | 95 | # Our dummy client is configured to raise an error if `#describe` is 96 | # invoked more than once. There is no "wont_raise" in Minitest. 97 | processor.process("CustomObject__c", attributes, :create) 98 | end 99 | end 100 | end 101 | 102 | end 103 | -------------------------------------------------------------------------------- /test/lib/restforce/db/initializer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Initializer do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:initializer) { Restforce::DB::Initializer.new(mapping) } 9 | 10 | describe "#run", vcr: { match_requests_on: [:method, VCR.request_matchers.uri_without_param(:q)] } do 11 | let(:attributes) do 12 | { 13 | "Name" => "Custom object", 14 | "Example_Field__c" => "Some sample text", 15 | } 16 | end 17 | let(:salesforce_id) { Salesforce.create!(salesforce_model, attributes) } 18 | 19 | describe "given an existing Salesforce record" do 20 | before do 21 | salesforce_id 22 | end 23 | 24 | describe "for an Always strategy" do 25 | before do 26 | mapping.strategy = Restforce::DB::Strategies::Always.new 27 | initializer.run 28 | end 29 | 30 | it "creates a matching database record" do 31 | record = database_model.last 32 | 33 | expect(record.name).to_equal attributes["Name"] 34 | expect(record.example).to_equal attributes["Example_Field__c"] 35 | expect(record.salesforce_id).to_equal salesforce_id 36 | end 37 | end 38 | 39 | describe "for a Passive strategy" do 40 | before do 41 | mapping.strategy = Restforce::DB::Strategies::Passive.new 42 | initializer.run 43 | end 44 | 45 | it "does not create a database record" do 46 | expect(database_model.last).to_be_nil 47 | end 48 | end 49 | end 50 | 51 | describe "given an existing database record" do 52 | let(:database_record) do 53 | database_model.create!(mapping.convert(database_model, attributes)) 54 | end 55 | let(:salesforce_id) { database_record.reload.salesforce_id } 56 | 57 | before do 58 | database_record 59 | end 60 | 61 | describe "for an Always strategy" do 62 | before do 63 | mapping.strategy = Restforce::DB::Strategies::Always.new 64 | initializer.run 65 | Salesforce.records << [salesforce_model, salesforce_id] 66 | end 67 | 68 | it "populates Salesforce with the new record" do 69 | record = mapping.salesforce_record_type.find(salesforce_id).record 70 | 71 | expect(record.Name).to_equal attributes["Name"] 72 | expect(record.Example_Field__c).to_equal attributes["Example_Field__c"] 73 | end 74 | end 75 | 76 | describe "for a Passive strategy" do 77 | before do 78 | mapping.strategy = Restforce::DB::Strategies::Passive.new 79 | initializer.run 80 | end 81 | 82 | it "does not create a Salesforce record" do 83 | expect(salesforce_id).to_be_nil 84 | end 85 | end 86 | end 87 | 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/lib/restforce/db/instances/active_record_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::Instances::ActiveRecord do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:record) { CustomObject.create! } 9 | let(:instance) do 10 | Restforce::DB::Instances::ActiveRecord.new(database_model, record, mapping) 11 | end 12 | 13 | describe "#id" do 14 | 15 | describe "when the record has no synchronized Salesforce ID" do 16 | 17 | it "returns the record's UUID" do 18 | expect(instance.id).to_equal instance.uuid 19 | end 20 | end 21 | 22 | describe "when the record has a synchronized Salesforce ID" do 23 | let(:salesforce_id) { "a001a000001E1vREAL" } 24 | before do 25 | record.update!(salesforce_id: salesforce_id) 26 | end 27 | 28 | it "returns the Salesforce ID" do 29 | expect(instance.id).to_equal salesforce_id 30 | end 31 | end 32 | end 33 | 34 | describe "#uuid" do 35 | 36 | it "returns the record's ID" do 37 | expect(instance.uuid).to_equal "CustomObject::#{record.id}" 38 | end 39 | end 40 | 41 | describe "#update!" do 42 | let(:text) { "Some new text" } 43 | 44 | before do 45 | instance.update!(example: text) 46 | end 47 | 48 | it "updates the local record with the passed attributes" do 49 | expect(record.example).to_equal text 50 | end 51 | 52 | it "updates the record in Salesforce with the passed attributes" do 53 | expect(record.reload.example).to_equal text 54 | end 55 | 56 | it "bumps the record's synchronized_at timestamp" do 57 | expect(record.reload.synchronized_at).to_not_be_nil 58 | end 59 | 60 | describe "when the passed attributes match the current values" do 61 | let(:text) { record.example } 62 | 63 | it "does not bump the record's synchronized_at timestamp" do 64 | expect(record.reload.synchronized_at).to_be_nil 65 | end 66 | end 67 | end 68 | 69 | describe "#updated_internally?" do 70 | 71 | describe "when updated_at exceeds synchronized_at" do 72 | before do 73 | record.update!(synchronized_at: Time.now - 2) 74 | end 75 | 76 | it "returns false" do 77 | expect(instance).to_not_be :updated_internally? 78 | end 79 | end 80 | 81 | describe "when synchronized_at meets or exceeds updated_at" do 82 | before do 83 | record.touch(:synchronized_at) 84 | end 85 | 86 | it "returns true" do 87 | expect(instance).to_be :updated_internally? 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/lib/restforce/db/instances/salesforce_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::Instances::Salesforce do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:id) { Salesforce.create!(salesforce_model) } 9 | let(:instance) { mapping.salesforce_record_type.find(id) } 10 | 11 | describe "#update!", :vcr do 12 | let(:text) { "Some new text" } 13 | 14 | before do 15 | instance.update!("Example_Field__c" => text) 16 | end 17 | 18 | it "updates the local record with the passed attributes" do 19 | expect(instance.record.Example_Field__c).to_equal text 20 | end 21 | 22 | it "updates the record in Salesforce with the passed attributes" do 23 | expect(mapping.salesforce_record_type.find(id).record.Example_Field__c).to_equal text 24 | end 25 | end 26 | 27 | describe "#last_update" do 28 | let(:timestamp) { "2015-03-18T20:28:24.000+0000" } 29 | let(:record) { Struct.new(:SystemModstamp).new(timestamp) } 30 | let(:instance) { Restforce::DB::Instances::Salesforce.new(salesforce_model, record) } 31 | 32 | it "parses a time from the record's system modification timestamp" do 33 | expect(instance.last_update).to_equal(Time.new(2015, 3, 18, 20, 28, 24, 0)) 34 | end 35 | end 36 | 37 | describe "#synced?", :vcr do 38 | 39 | describe "when no matching database record exists" do 40 | 41 | it "returns false" do 42 | expect(instance).to_not_be :synced? 43 | end 44 | end 45 | 46 | describe "when a matching database record exists" do 47 | before do 48 | database_model.create!(salesforce_id: id) 49 | end 50 | 51 | it "returns true" do 52 | expect(instance).to_be :synced? 53 | end 54 | end 55 | end 56 | 57 | describe "#updated_internally?", :vcr do 58 | 59 | describe "when our client made the last change" do 60 | 61 | it "returns true" do 62 | expect(instance).to_be :updated_internally? 63 | end 64 | end 65 | 66 | describe "when another user made the last change" do 67 | let(:user_id) { "a001a000001E1vFAKE" } 68 | let(:record) { Struct.new(:LastModifiedById).new(user_id) } 69 | let(:instance) { Restforce::DB::Instances::Salesforce.new(salesforce_model, record) } 70 | 71 | it "returns false" do 72 | expect(instance).to_not_be :updated_internally? 73 | end 74 | end 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /test/lib/restforce/db/mapping_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Mapping do 4 | 5 | configure! 6 | 7 | let(:database_model) { CustomObject } 8 | let(:salesforce_model) { "CustomObject__c" } 9 | let(:fields) do 10 | { 11 | column_one: "SF_Field_One__c", 12 | column_two: "SF_Field_Two__c", 13 | } 14 | end 15 | let(:mapping) do 16 | Restforce::DB::Mapping.new(database_model, salesforce_model).tap do |map| 17 | map.fields = fields 18 | end 19 | end 20 | 21 | describe "#initialize" do 22 | 23 | it "defaults to an initialization strategy of `Always`" do 24 | expect(mapping.strategy).to_be_instance_of(Restforce::DB::Strategies::Always) 25 | end 26 | end 27 | 28 | describe "#salesforce_fields" do 29 | 30 | describe "given no mapped associations" do 31 | 32 | it "lists the fields in the attribute map" do 33 | expect(mapping.salesforce_fields).to_equal(fields.values) 34 | end 35 | end 36 | 37 | describe "given a set of associations" do 38 | let(:association) do 39 | Restforce::DB::Associations::BelongsTo.new(:user, through: "Owner") 40 | end 41 | 42 | before do 43 | mapping.associations << association 44 | end 45 | 46 | it "lists the field and association lookups" do 47 | expect(mapping.salesforce_fields).to_equal(fields.values + association.fields) 48 | end 49 | end 50 | end 51 | 52 | describe "#lookup_column" do 53 | let(:db) { mapping.database_record_type } 54 | 55 | describe "when the database table has a column matching the Salesforce model" do 56 | before do 57 | def db.column?(_) 58 | true 59 | end 60 | end 61 | 62 | it "returns the explicit column name" do 63 | expect(mapping.lookup_column).to_equal(:custom_object_salesforce_id) 64 | end 65 | end 66 | 67 | describe "when the database table has a generic salesforce ID column" do 68 | before do 69 | def db.column?(column) 70 | column == :salesforce_id 71 | end 72 | end 73 | 74 | it "returns the generic column name" do 75 | expect(mapping.lookup_column).to_equal(:salesforce_id) 76 | end 77 | end 78 | 79 | describe "when the database table has no salesforce ID column" do 80 | before do 81 | def db.column?(_) 82 | false 83 | end 84 | end 85 | 86 | it "raises an error" do 87 | expect(-> { mapping.lookup_column }).to_raise Restforce::DB::Mapping::InvalidMappingError 88 | end 89 | end 90 | end 91 | 92 | describe "#unscoped" do 93 | before do 94 | mapping.conditions = ["Some_Condition__c = TRUE"] 95 | end 96 | 97 | it "removes the conditions from the mapping within the context of the block" do 98 | expect(mapping.conditions).to_not_be :empty? 99 | mapping.unscoped { |map| expect(map.conditions).to_be :empty? } 100 | expect(mapping.conditions).to_not_be :empty? 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/lib/restforce/db/model_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Model do 4 | 5 | configure! 6 | 7 | describe "given a database model which includes the module" do 8 | let(:database_model) { CustomObject } 9 | let(:salesforce_model) { "CustomObject__c" } 10 | 11 | before do 12 | database_model.send(:include, Restforce::DB::Model) 13 | end 14 | 15 | describe ".sync_with" do 16 | before do 17 | database_model.sync_with(salesforce_model) do 18 | maps( 19 | name: "Name", 20 | example: "Example_Field__c", 21 | ) 22 | end 23 | end 24 | 25 | it "adds a mapping to the global Restforce::DB::Registry" do 26 | expect(Restforce::DB::Registry[database_model]).to_not_be :empty? 27 | end 28 | end 29 | 30 | describe "#force_sync!", :vcr do 31 | let(:mapping) { Restforce::DB::Registry[database_model].first } 32 | 33 | before do 34 | database_model.sync_with(salesforce_model) do 35 | maps( 36 | name: "Name", 37 | example: "Example_Field__c", 38 | ) 39 | end 40 | end 41 | 42 | describe "given an unpersisted record for a mapped model" do 43 | let(:record) { database_model.new } 44 | 45 | it "does nothing" do 46 | expect(record.force_sync!).to_equal false 47 | end 48 | end 49 | 50 | describe "given an unsynchronized record for a mapped model" do 51 | let(:record) { database_model.create!(attributes) } 52 | let(:attributes) do 53 | { 54 | name: "Frederick's Flip-flop", 55 | example: "Yes, we only have the left one.", 56 | } 57 | end 58 | 59 | before do 60 | expect(record.force_sync!).to_equal true 61 | Salesforce.records << [salesforce_model, record.salesforce_id] 62 | end 63 | 64 | it "creates a matching record in Salesforce" do 65 | salesforce_record = mapping.salesforce_record_type.find( 66 | record.salesforce_id, 67 | ).record 68 | 69 | expect(salesforce_record.Name).to_equal attributes[:name] 70 | expect(salesforce_record.Example_Field__c).to_equal attributes[:example] 71 | end 72 | end 73 | 74 | describe "given a previously-synchronized record for a mapped model" do 75 | let(:attributes) do 76 | { 77 | name: "Sally's Seashells", 78 | example: "She sells them down by the seashore.", 79 | } 80 | end 81 | let(:salesforce_id) do 82 | Salesforce.create!( 83 | salesforce_model, 84 | "Name" => attributes[:name], 85 | "Example_Field__c" => attributes[:example], 86 | ) 87 | end 88 | let(:record) { database_model.create!(attributes.merge(salesforce_id: salesforce_id)) } 89 | 90 | it "force-updates both synchronized records" do 91 | record.update!(name: "Sarah's Seagulls") 92 | expect(record.force_sync!).to_equal true 93 | 94 | salesforce_record = mapping.salesforce_record_type.find(salesforce_id).record 95 | expect(salesforce_record.Name).to_equal record.name 96 | end 97 | 98 | describe "and a mutually exclusive mapping" do 99 | let(:other_mapping) do 100 | Restforce::DB::Mapping.new(database_model, salesforce_model).tap do |map| 101 | map.conditions = [ 102 | "Example_Field__c != '#{attributes[:example]}'", 103 | ] 104 | end 105 | end 106 | 107 | before do 108 | Restforce::DB::Registry << other_mapping 109 | end 110 | 111 | it "ignores the problematic mapping" do 112 | record.update!(name: "Sarah's Seagulls") 113 | expect(record.force_sync!).to_equal true 114 | 115 | salesforce_record = mapping.salesforce_record_type.find(salesforce_id).record 116 | expect(salesforce_record.Name).to_equal record.name 117 | end 118 | end 119 | end 120 | end 121 | end 122 | 123 | end 124 | -------------------------------------------------------------------------------- /test/lib/restforce/db/record_cache_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::RecordCache do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:cache) { Restforce::DB::RecordCache.new } 9 | 10 | describe "#collection" do 11 | let(:object) { mapping.database_model.create! } 12 | 13 | before { object } 14 | 15 | it "invokes the specified collection for the mapping" do 16 | instances = cache.collection(mapping, :database_record_type) 17 | expect(instances.first.record).to_equal object 18 | end 19 | 20 | describe "on repeated calls for the same mapping" do 21 | let(:dummy_collection) do 22 | Object.new.tap do |collection| 23 | 24 | def collection.values 25 | [1, 2, 3] 26 | end 27 | 28 | def collection.all(*_) 29 | values 30 | end 31 | 32 | end 33 | end 34 | 35 | before do 36 | cache.collection(mapping, :database_record_type) 37 | end 38 | 39 | it "does not re-invoke the original method call" do 40 | mapping.stub(:database_record_type, dummy_collection) do 41 | instances = cache.collection(mapping, :database_record_type) 42 | expect(instances.first.record).to_equal object 43 | end 44 | end 45 | 46 | describe "when the mapping's conditions have been modified" do 47 | before do 48 | mapping.conditions = ["Name != null"] 49 | end 50 | 51 | it "caches the mapping separately, and re-invokes the original method call" do 52 | mapping.stub(:database_record_type, dummy_collection) do 53 | instances = cache.collection(mapping, :database_record_type) 54 | expect(instances).to_equal dummy_collection.values 55 | end 56 | end 57 | end 58 | end 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /test/lib/restforce/db/record_types/active_record_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::RecordTypes::ActiveRecord do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:record_type) { mapping.database_record_type } 9 | let(:salesforce_id) { "a001a000001E1vREAL" } 10 | 11 | describe "#create!" do 12 | let(:attributes) do 13 | { 14 | "Name" => "Some name", 15 | "Example_Field__c" => "Some text", 16 | } 17 | end 18 | let(:record) { nil } 19 | let(:create_from) do 20 | Struct.new(:id, :last_update, :attributes, :record).new( 21 | salesforce_id, 22 | Time.now, 23 | attributes, 24 | record, 25 | ) 26 | end 27 | let(:instance) { record_type.create!(create_from).record } 28 | 29 | before do 30 | Restforce::DB::Registry << mapping 31 | end 32 | 33 | it "creates a record in the database from the passed Salesforce record's attributes" do 34 | expect(instance.salesforce_id).to_equal salesforce_id 35 | expect(instance.name).to_equal attributes["Name"] 36 | expect(instance.example).to_equal attributes["Example_Field__c"] 37 | expect(instance.synchronized_at).to_not_be_nil 38 | end 39 | 40 | describe "given a mapped association" do 41 | let(:record) { Struct.new(:Friend__c).new(association_id) } 42 | let(:association_id) { "a001a000001EFRIEND" } 43 | 44 | before do 45 | mapping.associations << Restforce::DB::Associations::BelongsTo.new( 46 | :user, 47 | through: "Friend__c", 48 | ) 49 | 50 | associated_mapping = Restforce::DB::Mapping.new(User, "Contact").tap do |map| 51 | map.fields = { email: "Email" } 52 | map.associations << Restforce::DB::Associations::HasOne.new( 53 | :custom_object, 54 | through: "Friend__c", 55 | ) 56 | end 57 | Restforce::DB::Registry << associated_mapping 58 | 59 | salesforce_record_type = associated_mapping.salesforce_record_type 60 | 61 | # Stub out the `#find` method on the record type 62 | def salesforce_record_type.find(id) 63 | Struct.new(:id, :last_update, :mapping, :attributes).new( 64 | id, 65 | Time.now, 66 | Restforce::DB::Registry[User].first, 67 | "Email" => "somebody@example.com", 68 | ) 69 | end 70 | end 71 | 72 | it "creates the associated record from the related Salesforce record's attributes" do 73 | user = instance.reload.user 74 | 75 | expect(user).to_not_be_nil 76 | expect(user.email).to_equal("somebody@example.com") 77 | expect(user.salesforce_id).to_equal(association_id) 78 | expect(user.synchronized_at).to_not_be_nil 79 | expect(user.synchronized_at).to_be(:>=, user.updated_at) 80 | end 81 | end 82 | end 83 | 84 | describe "#find" do 85 | 86 | it "finds existing records in the database by their salesforce id" do 87 | database_model.create!(salesforce_id: salesforce_id) 88 | expect(record_type.find(salesforce_id)).to_be_instance_of Restforce::DB::Instances::ActiveRecord 89 | end 90 | 91 | it "returns nil when no matching record exists" do 92 | expect(record_type.find("a001a000001E1vFAKE")).to_be_nil 93 | end 94 | end 95 | 96 | describe "#destroy_all" do 97 | before do 98 | database_model.create!(salesforce_id: salesforce_id) 99 | record_type.destroy_all(ids) 100 | end 101 | 102 | describe "when the passed ids include the Salesforce ID of an existing record" do 103 | let(:ids) { [salesforce_id] } 104 | 105 | it "eliminates the matching record(s)" do 106 | expect(database_model.last).to_be_nil 107 | end 108 | end 109 | 110 | describe "when the passed ids do not include the Salesforce ID of an existing record" do 111 | let(:ids) { ["a001a000001E1vFAKE"] } 112 | 113 | it "does not eliminate the matching record(s)" do 114 | expect(database_model.last).to_not_be_nil 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/lib/restforce/db/record_types/salesforce_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::RecordTypes::Salesforce do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:record_type) { mapping.salesforce_record_type } 9 | 10 | describe "#create!", :vcr do 11 | let(:database_record) do 12 | database_model.create!( 13 | name: "Something", 14 | example: "Something else", 15 | ) 16 | end 17 | let(:sync_from) { Restforce::DB::Instances::ActiveRecord.new(database_model, database_record, mapping) } 18 | let(:instance) { record_type.create!(sync_from).record } 19 | 20 | it "creates a record in Salesforce from the passed database record's attributes" do 21 | Salesforce.records << [salesforce_model, instance.Id] 22 | expect(instance.Name).to_equal database_record.name 23 | expect(instance.Example_Field__c).to_equal database_record.example 24 | end 25 | 26 | it "updates the database record with the Salesforce record's ID" do 27 | Salesforce.records << [salesforce_model, instance.Id] 28 | expect(sync_from.synced?).to_equal(true) 29 | end 30 | 31 | it "wipes the temporary SynchronizationId__c value used for upsert" do 32 | Salesforce.records << [salesforce_model, instance.Id] 33 | expect(instance.SynchronizationId__c).to_be_nil 34 | end 35 | 36 | describe "when a Salesforce record already exists for the database instance" do 37 | 38 | it "uses the existing record" do 39 | salesforce_id = Salesforce.create!( 40 | salesforce_model, 41 | "SynchronizationId__c" => sync_from.uuid, 42 | ) 43 | 44 | expect(instance.Id).to_equal salesforce_id 45 | end 46 | end 47 | end 48 | 49 | describe "#find", :vcr do 50 | let(:id) { Salesforce.create!(salesforce_model) } 51 | 52 | it "finds existing records in Salesforce" do 53 | expect(record_type.find(id)).to_be_instance_of Restforce::DB::Instances::Salesforce 54 | end 55 | 56 | it "returns nil when no matching record exists" do 57 | expect(record_type.find("a001a000001E1vFAKE")).to_be_nil 58 | end 59 | 60 | describe "given a set of mapping conditions" do 61 | let(:conditions) { ["Visible__c = TRUE"] } 62 | 63 | describe "when a record meets the conditions" do 64 | 65 | it "finds the record" do 66 | expect(record_type.find(id)).to_be_instance_of Restforce::DB::Instances::Salesforce 67 | end 68 | end 69 | 70 | describe "when a record does not meet the conditions" do 71 | let(:id) { Salesforce.create!(salesforce_model, "Visible__c" => false) } 72 | 73 | it "does not find the record" do 74 | expect(record_type.find(id)).to_be_nil 75 | end 76 | end 77 | end 78 | end 79 | 80 | describe "#all", :vcr do 81 | let(:id) { Salesforce.create!(salesforce_model) } 82 | before { id } 83 | 84 | it "returns a list of the existing records in Salesforce" do 85 | record = record_type.all.first 86 | expect(record).to_be_instance_of(Restforce::DB::Instances::Salesforce) 87 | expect(record.id).to_equal(id) 88 | end 89 | end 90 | 91 | end 92 | -------------------------------------------------------------------------------- /test/lib/restforce/db/registry_test.rb: -------------------------------------------------------------------------------- 1 | describe Restforce::DB::Registry do 2 | 3 | configure! 4 | mappings! 5 | 6 | describe ".<<" do 7 | before do 8 | Restforce::DB::Registry << mapping 9 | end 10 | 11 | it "appends a mapping in the registry under its ActiveRecord class" do 12 | expect(Restforce::DB::Registry[database_model]).to_equal [mapping] 13 | end 14 | 15 | it "appends a mapping in the registry under its Salesforce object type" do 16 | expect(Restforce::DB::Registry[salesforce_model]).to_equal [mapping] 17 | end 18 | end 19 | 20 | describe ".each" do 21 | before do 22 | Restforce::DB::Registry << mapping 23 | end 24 | 25 | # Restforce::DB::Registry actually implements Enumerable, so we're just 26 | # going with a trivially testable portion of the Enumerable API. 27 | it "yields the registered record types" do 28 | expect(Restforce::DB::Registry.first).to_equal mapping 29 | end 30 | end 31 | 32 | describe ".clean!" do 33 | before do 34 | Restforce::DB::Registry << mapping 35 | end 36 | 37 | it "clears out the currently registered mappings" do 38 | expect(Restforce::DB::Registry.first).to_not_be_nil 39 | Restforce::DB::Registry.clean! 40 | expect(Restforce::DB::Registry.first).to_be_nil 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/lib/restforce/db/runner_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Runner do 4 | 5 | configure! 6 | 7 | let(:runner) { Restforce::DB::Runner.new } 8 | 9 | describe "#initialize" do 10 | before { Restforce::DB.last_run = Time.now } 11 | 12 | it "prefills the Collector's last_run timestamp with the global configuration" do 13 | expect(runner.last_run).to_equal Restforce::DB.last_run 14 | end 15 | end 16 | 17 | describe "#tick!" do 18 | before { Restforce::DB.last_run = Time.now } 19 | 20 | it "updates the run timestamps" do 21 | prior_run = runner.last_run 22 | new_run = runner.tick! 23 | 24 | expect(runner.last_run).to_equal new_run 25 | expect(runner.before).to_equal new_run 26 | expect(runner.after).to_equal prior_run 27 | end 28 | 29 | describe "with a configured delay" do 30 | let(:delay) { 5 } 31 | let(:runner) { Restforce::DB::Runner.new(delay) } 32 | 33 | it "offsets the timestamps" do 34 | prior_run = runner.last_run 35 | new_run = runner.tick! 36 | 37 | expect(runner.before).to_equal new_run - delay 38 | expect(runner.after).to_equal prior_run - delay 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/lib/restforce/db/strategies/always_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::Strategies::Always do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:strategy) { Restforce::DB::Strategies::Always.new } 9 | 10 | it "is not a passive strategy" do 11 | expect(strategy).to_not_be :passive? 12 | end 13 | 14 | describe "#build?", :vcr do 15 | 16 | describe "given a Salesforce record" do 17 | let(:salesforce_id) { Salesforce.create! salesforce_model } 18 | let(:record) { mapping.salesforce_record_type.find(salesforce_id) } 19 | 20 | it "wants to build a new matching record" do 21 | expect(strategy).to_be :build?, record 22 | end 23 | 24 | describe "with a corresponding database record" do 25 | before do 26 | CustomObject.create!( 27 | salesforce_id: salesforce_id, 28 | synchronized_at: Time.now, 29 | ) 30 | end 31 | 32 | it "does not want to build a new record" do 33 | expect(strategy).to_not_be :build?, record 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/lib/restforce/db/strategies/associated_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::Strategies::Associated do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:strategy) { Restforce::DB::Strategies::Associated.new(with: :custom_object) } 9 | 10 | it "is not a passive strategy" do 11 | expect(strategy).to_not_be :passive? 12 | end 13 | 14 | describe "#build?", :vcr do 15 | 16 | describe "given an inverse mapping" do 17 | let(:inverse_mapping) do 18 | Restforce::DB::Mapping.new(Detail, "CustomObjectDetail__c").tap do |map| 19 | map.fields = { name: "Name" } 20 | map.associations << Restforce::DB::Associations::BelongsTo.new( 21 | :custom_object, 22 | through: "CustomObject__c", 23 | ) 24 | end 25 | end 26 | let(:object_salesforce_id) { Salesforce.create!(mapping.salesforce_model) } 27 | let(:detail_salesforce_id) do 28 | Salesforce.create!( 29 | inverse_mapping.salesforce_model, 30 | "CustomObject__c" => object_salesforce_id, 31 | ) 32 | end 33 | let(:record) { inverse_mapping.salesforce_record_type.find(detail_salesforce_id) } 34 | 35 | before do 36 | Restforce::DB::Registry << mapping 37 | Restforce::DB::Registry << inverse_mapping 38 | mapping.associations << Restforce::DB::Associations::HasMany.new( 39 | :details, 40 | through: "CustomObject__c", 41 | ) 42 | end 43 | 44 | describe "with no synchronized association record" do 45 | 46 | it "does not want to build a new record" do 47 | expect(strategy).to_not_be :build?, record 48 | end 49 | end 50 | 51 | describe "with an existing database record" do 52 | before do 53 | Detail.create!( 54 | salesforce_id: detail_salesforce_id, 55 | synchronized_at: Time.now, 56 | ) 57 | end 58 | 59 | it "does not want to build a new record" do 60 | expect(strategy).to_not_be :build?, record 61 | end 62 | end 63 | 64 | describe "with a synchronized association record" do 65 | before do 66 | CustomObject.create!( 67 | salesforce_id: object_salesforce_id, 68 | synchronized_at: Time.now, 69 | ) 70 | end 71 | 72 | it "wants to build a new record" do 73 | expect(strategy).to_be :build?, record 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/lib/restforce/db/strategies/passive_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../test_helper" 2 | 3 | describe Restforce::DB::Strategies::Passive do 4 | 5 | configure! 6 | 7 | let(:strategy) { Restforce::DB::Strategies::Passive.new } 8 | 9 | it "is a passive strategy" do 10 | expect(strategy).to_be :passive? 11 | end 12 | 13 | describe "#build?" do 14 | 15 | it "returns false for any record" do 16 | record = Object.new 17 | expect(strategy).to_not_be :build?, record 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/lib/restforce/db/strategy_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Strategy do 4 | 5 | configure! 6 | 7 | describe ".for" do 8 | let(:strategy) { Restforce::DB::Strategy.for(type) } 9 | 10 | describe ":always" do 11 | let(:type) { :always } 12 | it { expect(strategy).to_be_instance_of(Restforce::DB::Strategies::Always) } 13 | end 14 | 15 | describe ":always" do 16 | let(:type) { :passive } 17 | it { expect(strategy).to_be_instance_of(Restforce::DB::Strategies::Passive) } 18 | end 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /test/lib/restforce/db/synchronizer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Synchronizer do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:synchronizer) { Restforce::DB::Synchronizer.new(mapping) } 9 | 10 | describe "#run", vcr: { match_requests_on: [:method, VCR.request_matchers.uri_without_param(:q)] } do 11 | let(:attributes) do 12 | { 13 | "Name" => "Custom object", 14 | "Example_Field__c" => "Some sample text", 15 | } 16 | end 17 | let(:salesforce_id) { Salesforce.create!(salesforce_model, attributes) } 18 | let(:timestamp) { Time.now } 19 | let(:changes) { { [salesforce_id, salesforce_model] => accumulator } } 20 | let(:new_attributes) do 21 | { 22 | "Name" => "Some new name", 23 | "Example_Field__c" => "New sample text", 24 | } 25 | end 26 | let(:accumulator) do 27 | Restforce::DB::Accumulator.new.tap do |accumulator| 28 | accumulator.store(timestamp, new_attributes) 29 | end 30 | end 31 | 32 | describe "given a Salesforce record with no associated database record" do 33 | before do 34 | salesforce_id 35 | synchronizer.run(changes) 36 | end 37 | 38 | it "does nothing for this specific mapping" do 39 | record = mapping.salesforce_record_type.find(salesforce_id) 40 | expect(record.attributes).to_equal attributes 41 | end 42 | end 43 | 44 | describe "given a Salesforce record with an associated database record" do 45 | let(:database_attributes) do 46 | { 47 | name: "Some existing name", 48 | example: "Some existing sample text", 49 | synchronized_at: Time.now, 50 | } 51 | end 52 | let(:database_record) do 53 | database_model.create!(database_attributes.merge(salesforce_id: salesforce_id)) 54 | end 55 | 56 | describe "when the changes are current" do 57 | before do 58 | database_record 59 | synchronizer.run(changes) 60 | end 61 | 62 | it "updates the database record" do 63 | record = database_record.reload 64 | 65 | expect(record.name).to_equal new_attributes["Name"] 66 | expect(record.example).to_equal new_attributes["Example_Field__c"] 67 | end 68 | 69 | it "updates the salesforce record" do 70 | record = mapping.salesforce_record_type.find(salesforce_id).record 71 | 72 | expect(record.Name).to_equal new_attributes["Name"] 73 | expect(record.Example_Field__c).to_equal new_attributes["Example_Field__c"] 74 | end 75 | end 76 | 77 | describe "when the change timestamp is stale" do 78 | let(:timestamp) do 79 | mapping.salesforce_record_type.find(salesforce_id).last_update - 1 80 | end 81 | 82 | before do 83 | database_record 84 | 85 | Restforce::DB::Instances::ActiveRecord.stub_any_instance(:updated_internally?, false) do 86 | Restforce::DB::Instances::Salesforce.stub_any_instance(:updated_internally?, false) do 87 | synchronizer.run(changes) 88 | end 89 | end 90 | end 91 | 92 | it "does not update the database record" do 93 | record = database_record.reload 94 | 95 | expect(record.name).to_equal database_attributes[:name] 96 | expect(record.example).to_equal database_attributes[:example] 97 | end 98 | 99 | it "does not update the salesforce record" do 100 | record = mapping.salesforce_record_type.find(salesforce_id).record 101 | 102 | expect(record.Name).to_equal attributes["Name"] 103 | expect(record.Example_Field__c).to_equal attributes["Example_Field__c"] 104 | end 105 | end 106 | 107 | end 108 | 109 | end 110 | 111 | end 112 | -------------------------------------------------------------------------------- /test/lib/restforce/db/timestamp_cache_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::TimestampCache do 4 | 5 | configure! 6 | 7 | let(:cache) { Restforce::DB::TimestampCache.new } 8 | 9 | let(:timestamp) { Time.now } 10 | let(:id) { "some-id" } 11 | let(:record_type) { "CustomObject__c" } 12 | let(:instance_class) { Struct.new(:id, :record_type, :last_update) } 13 | let(:instance) { instance_class.new(id, record_type, timestamp) } 14 | 15 | describe "#cache_timestamp" do 16 | before { cache.cache_timestamp instance } 17 | 18 | it "stores the update timestamp in the cache" do 19 | expect(cache.timestamp(instance)).to_equal timestamp 20 | end 21 | end 22 | 23 | describe "#changed?" do 24 | let(:new_instance) { instance_class.new(id, record_type, timestamp) } 25 | 26 | describe "when the passed instance was not internally updated" do 27 | before do 28 | def new_instance.updated_internally? 29 | false 30 | end 31 | end 32 | 33 | it "returns true" do 34 | expect(cache).to_be :changed?, new_instance 35 | end 36 | end 37 | 38 | describe "when the passed instance was internally updated" do 39 | before do 40 | def new_instance.updated_internally? 41 | true 42 | end 43 | end 44 | 45 | describe "but no update timestamp is cached" do 46 | 47 | it "returns true" do 48 | expect(cache).to_be :changed?, new_instance 49 | end 50 | end 51 | 52 | describe "and a recent timestamp is cached" do 53 | before { cache.cache_timestamp instance } 54 | 55 | it "returns false" do 56 | expect(cache).to_not_be :changed?, new_instance 57 | end 58 | end 59 | 60 | describe "and a stale timestamp is cached" do 61 | let(:new_instance) { instance_class.new(id, record_type, timestamp + 1) } 62 | before { cache.cache_timestamp instance } 63 | 64 | it "returns true" do 65 | expect(cache).to_be :changed?, new_instance 66 | end 67 | end 68 | end 69 | end 70 | 71 | describe "#reset" do 72 | before do 73 | cache.cache_timestamp instance 74 | cache.reset 75 | end 76 | 77 | it "retires recently-stored timestamps" do 78 | expect(cache.timestamp(instance)).to_equal timestamp 79 | end 80 | 81 | it "expires retired timestamps" do 82 | cache.reset 83 | expect(cache.timestamp(instance)).to_be_nil 84 | end 85 | end 86 | 87 | describe "I/O operations" do 88 | let(:io) { IO.pipe } 89 | let(:reader) { io.first } 90 | let(:writer) { io.last } 91 | 92 | describe "#dump_timestamps" do 93 | before do 94 | cache.cache_timestamp instance 95 | cache.dump_timestamps(writer) 96 | writer.close 97 | end 98 | 99 | it "writes a YAML dump of the cache to the passed I/O object" do 100 | expect(YAML.load(reader.read)).to_equal [record_type, id] => timestamp 101 | end 102 | end 103 | 104 | describe "#load_timestamps" do 105 | before do 106 | YAML.dump({ [record_type, id] => timestamp }, writer) 107 | writer.close 108 | cache.load_timestamps(reader) 109 | end 110 | 111 | it "reloads its internal cache from the passed I/O object" do 112 | expect(cache.timestamp(instance)).to_equal timestamp 113 | end 114 | end 115 | end 116 | 117 | end 118 | -------------------------------------------------------------------------------- /test/lib/restforce/db/tracker_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Tracker do 4 | 5 | configure! 6 | 7 | let(:file) { Tempfile.new(".restforce-db") } 8 | let(:tracker) { Restforce::DB::Tracker.new(file.path) } 9 | let(:runtime) { Time.now } 10 | 11 | describe "#initialize" do 12 | 13 | describe "when no timestamp has been recorded" do 14 | before { tracker } 15 | 16 | it "does not initialize Restforce::DB.last_run" do 17 | expect(Restforce::DB.last_run).to_be_nil 18 | end 19 | end 20 | 21 | describe "when a timestamp has been recorded in the file" do 22 | before do 23 | file.print runtime.iso8601 24 | file.rewind 25 | 26 | tracker 27 | end 28 | 29 | it "initializes Restforce::DB.last_run to the recorded time" do 30 | expect(Restforce::DB.last_run.to_i).to_equal runtime.to_i 31 | end 32 | 33 | it "initializes the tracker's last_run to the recorded time" do 34 | expect(tracker.last_run.to_i).to_equal runtime.to_i 35 | end 36 | end 37 | end 38 | 39 | describe "#track" do 40 | before { tracker.track runtime } 41 | 42 | it "records the supplied timestamp in the file" do 43 | expect(file.read).to_equal runtime.iso8601 44 | end 45 | 46 | it "updates the tracker's last_run to the recorded time" do 47 | expect(tracker.last_run.to_i).to_equal runtime.to_i 48 | end 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /test/lib/restforce/db/worker_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../test_helper" 2 | 3 | describe Restforce::DB::Worker do 4 | 5 | configure! 6 | mappings! 7 | 8 | let(:worker) { Restforce::DB::Worker.new(delay: 0) } 9 | let(:runner) { worker.send(:runner) } 10 | 11 | describe "a race condition during synchronization", vcr: { match_requests_on: [:method, VCR.request_matchers.uri_without_param(:q)] } do 12 | let(:database_record) { mapping.database_model.create! } 13 | let(:new_name) { "A New User-Entered Name" } 14 | 15 | before do 16 | # 0. A record is added to the database. 17 | database_record 18 | 19 | # 1. The first loop runs 20 | 21 | ## 1b. The record is synced to Salesforce. 22 | worker.send :reset! 23 | manager = worker.send(:task_manager) 24 | manager.send :task, Restforce::DB::Initializer, mapping 25 | 26 | expect(database_record.reload).to_be :salesforce_id? 27 | Salesforce.records << [salesforce_model, database_record.salesforce_id] 28 | 29 | ## 1c. The database record is updated (externally) by a user. 30 | database_record.update! name: new_name 31 | 32 | # We stub `last_update` to get around issues with VCR's cached timestamp; 33 | # we need the Salesforce record timestamp to be contemporary with this 34 | # test run. 35 | Restforce::DB::Instances::Salesforce.stub_any_instance(:last_update, Time.now) do 36 | 37 | ## 1d. The record in Salesforce is touched by another mapping. 38 | salesforce_instance = mapping.salesforce_record_type.find( 39 | database_record.salesforce_id, 40 | ) 41 | salesforce_instance.update! "Name" => "A Stale Synchronized Name" 42 | runner.cache_timestamp salesforce_instance 43 | 44 | # 2. The second loop runs. 45 | # We sleep here to ensure we pick up our manual changes. 46 | sleep 1 if VCR.current_cassette.recording? 47 | worker.send :reset! 48 | manager = worker.send(:task_manager) 49 | manager.send :task, Restforce::DB::Collector, mapping 50 | manager.send :task, Restforce::DB::Synchronizer, mapping 51 | end 52 | end 53 | 54 | it "does not change the user-entered name on the database record" do 55 | expect(database_record.reload.name).to_equal new_name 56 | end 57 | 58 | it "overrides the stale-but-more-recent name on the Salesforce" do 59 | salesforce_instance = mapping.salesforce_record_type.find( 60 | database_record.salesforce_id, 61 | ) 62 | 63 | expect(salesforce_instance.record.Name).to_equal new_name 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/lib/restforce/db_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../test_helper" 2 | 3 | describe Restforce::DB do 4 | 5 | configure! 6 | 7 | describe ".configure" do 8 | 9 | it "yields a Configuration" do 10 | Restforce::DB.configure do |config| 11 | expect(config).to_be_instance_of(Restforce::DB::Configuration) 12 | end 13 | end 14 | end 15 | 16 | describe ".client" do 17 | before do 18 | Restforce::DB.configure do |config| 19 | config.adapter = :net_http 20 | end 21 | end 22 | 23 | it "adds the Retry middleware before the adapter" do 24 | handlers = Restforce::DB.client.middleware.handlers 25 | 26 | expect(handlers[-2].klass).to_equal(Faraday::Request::Retry) 27 | expect(handlers[-1].klass).to_equal(Faraday::Adapter::NetHttp) 28 | end 29 | end 30 | 31 | describe ".hashed_id" do 32 | 33 | it "returns an 18-character Salesforce ID untouched" do 34 | expect(Restforce::DB.hashed_id("a001a000002zhZfAAI")).to_equal("a001a000002zhZfAAI") 35 | end 36 | 37 | it "generates a proper hash for a 15-character Salesforce ID" do 38 | expect(Restforce::DB.hashed_id("aaaaaaaaaaaaaaa")).to_equal("aaaaaaaaaaaaaaaAAA") 39 | expect(Restforce::DB.hashed_id("AaaAAAaaAAAaaAA")).to_equal("AaaAAAaaAAAaaAAZZZ") 40 | expect(Restforce::DB.hashed_id("aAaAAaAaAAaAaAA")).to_equal("aAaAAaAaAAaAaAA000") 41 | expect(Restforce::DB.hashed_id("AAAAAAAAAAAAAAA")).to_equal("AAAAAAAAAAAAAAA555") 42 | 43 | expect(Restforce::DB.hashed_id("0063200001kSU3I")).to_equal("0063200001kSU3IAAW") 44 | end 45 | 46 | it "raises an ArgumentError if the passed String contains an invalid character count" do 47 | expect { Restforce::DB.hashed_id("a001a000002zhZfA") }.to_raise(ArgumentError) 48 | end 49 | end 50 | 51 | describe "accessing Salesforce", :vcr do 52 | 53 | it "uses the configured credentials" do 54 | expect(Restforce::DB.client.authenticate!.access_token).to_not_be_nil 55 | end 56 | 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /test/support/active_record.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | ActiveRecord::Base.logger = Logger.new("/dev/null") 4 | ActiveRecord::Base.establish_connection( 5 | adapter: "sqlite3", 6 | database: ":memory:", 7 | ) 8 | ActiveSupport::TestCase.test_order = :random 9 | 10 | ActiveRecord::Schema.define do 11 | 12 | create_table :custom_objects do |table| 13 | table.column :name, :string 14 | table.column :example, :string 15 | table.column :user_id, :integer 16 | table.column :salesforce_id, :string 17 | table.column :synchronized_at, :datetime 18 | table.timestamps null: false 19 | end 20 | 21 | add_index :custom_objects, :salesforce_id 22 | 23 | create_table :details do |table| 24 | table.column :name, :string 25 | table.column :custom_object_id, :integer 26 | table.column :salesforce_id, :string 27 | table.column :synchronized_at, :datetime 28 | table.timestamps null: false 29 | end 30 | 31 | add_index :details, :salesforce_id 32 | 33 | create_table :users do |table| 34 | table.column :email, :string 35 | table.column :favorite_id, :integer 36 | table.column :salesforce_id, :string 37 | table.column :synchronized_at, :datetime 38 | table.timestamps null: false 39 | end 40 | 41 | add_index :users, :salesforce_id 42 | 43 | end 44 | 45 | # :nodoc: 46 | class CustomObject < ActiveRecord::Base 47 | 48 | belongs_to :user, inverse_of: :custom_object, autosave: true 49 | has_many :admirers, class_name: "User", inverse_of: :favorite, foreign_key: :favorite_id 50 | has_many :details, inverse_of: :custom_object 51 | 52 | end 53 | 54 | # :nodoc: 55 | class Detail < ActiveRecord::Base 56 | 57 | belongs_to :custom_object, inverse_of: :details 58 | 59 | end 60 | 61 | # :nodoc: 62 | class User < ActiveRecord::Base 63 | 64 | has_one :custom_object, inverse_of: :user 65 | belongs_to :favorite, class_name: "CustomObject", inverse_of: :admirers, foreign_key: :favorite_id 66 | 67 | end 68 | -------------------------------------------------------------------------------- /test/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | require "database_cleaner" 2 | 3 | DatabaseCleaner.strategy = :truncation 4 | -------------------------------------------------------------------------------- /test/support/matchers.rb: -------------------------------------------------------------------------------- 1 | # https://jkotests.wordpress.com/2013/12/02/comparing-arrays-in-an-order-independent-manner-using-minitest/ 2 | module MiniTest 3 | 4 | # :nodoc: 5 | module Assertions 6 | 7 | # MatchArray performs an order-independent comparison of two arrays. 8 | class MatchArray 9 | 10 | # :nodoc: 11 | def initialize(expected, actual) 12 | @expected = expected 13 | @actual = actual 14 | end 15 | 16 | # :nodoc: 17 | def match 18 | [result, message] 19 | end 20 | 21 | # :nodoc: 22 | def result 23 | return false unless @actual.respond_to? :to_ary 24 | @extra_items = diff(@actual, @expected) 25 | @missing_items = diff(@expected, @actual) 26 | @extra_items.empty? & @missing_items.empty? 27 | end 28 | 29 | # :nodoc: 30 | def message 31 | if @actual.respond_to? :to_ary 32 | message = "expected collection contained: #{safe_sort(@expected).inspect}\n" 33 | message += "actual collection contained: #{safe_sort(@actual).inspect}\n" 34 | message += "the missing elements were: #{safe_sort(@missing_items).inspect}\n" unless @missing_items.empty? 35 | message += "the extra elements were: #{safe_sort(@extra_items).inspect}\n" unless @extra_items.empty? 36 | else 37 | message = "expected an array, actual collection was #{@actual.inspect}" 38 | end 39 | 40 | message 41 | end 42 | 43 | private 44 | 45 | # :nodoc: 46 | def safe_sort(array) 47 | array.sort rescue array 48 | end 49 | 50 | # :nodoc: 51 | def diff(first, second) 52 | first.to_ary - second.to_ary 53 | end 54 | 55 | end 56 | 57 | # Public: Assert that two Arrays are identical, save for the order of their 58 | # elements. 59 | # 60 | # Returns an assertion. 61 | def assert_match_array(expected, actual) 62 | result, message = MatchArray.new(expected, actual).match 63 | assert result, message 64 | end 65 | 66 | end 67 | 68 | # :nodoc: 69 | module Expectations 70 | 71 | infect_an_assertion :assert_match_array, :must_match_array 72 | 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /test/support/salesforce.rb: -------------------------------------------------------------------------------- 1 | # A small utility class to allow for transactional Salesforce record creation. 2 | class Salesforce 3 | 4 | class << self 5 | 6 | attr_accessor :records 7 | 8 | end 9 | 10 | self.records = [] 11 | 12 | # Public: Configure Restforce::DB for purposes of test execution. 13 | # 14 | # Returns nothing. 15 | def self.configure! 16 | Restforce::DB.configure { |config| config.load(Secrets["client"]) } 17 | end 18 | 19 | # Public: Create a basic instance of the passed Salesforce model. 20 | # 21 | # salesforce_model - The name of the model which should be created. 22 | # attributes - A Hash of attributes to assign to the created object. 23 | # 24 | # Returns a Salesforce record ID. 25 | def self.create!(salesforce_model, attributes = nil) 26 | attributes ||= { "Name" => "Sample object" } 27 | salesforce_id = Restforce::DB.client.create!(salesforce_model, attributes) 28 | Salesforce.records << [salesforce_model, salesforce_id] 29 | 30 | salesforce_id 31 | end 32 | 33 | # Public: Clean up any data which was added to Salesforce during the current 34 | # test run. For consistency in our tests, we reset the configuration and discard 35 | # our current client session each time this method is run. 36 | # 37 | # Returns nothing. 38 | def self.clean! 39 | Salesforce.records.each do |entry| 40 | Restforce::DB.client.destroy entry[0], entry[1] 41 | end 42 | Salesforce.records = [] 43 | 44 | Restforce::DB.reset 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /test/support/stub.rb: -------------------------------------------------------------------------------- 1 | # Extend all objects with `#stub_any_instance`. Implementation taken from: 2 | # https://github.com/codeodor/minitest-stub_any_instance 3 | class Object 4 | 5 | # Public: Stub the specified method for any instance of the passed class 6 | # within the context of a block. 7 | # 8 | # name - A String or Symbol method name. 9 | # val_or_callable - The value which the stubbed method should return. 10 | # block - A block of code to execute in this context. 11 | # 12 | # Returns nothing. 13 | def self.stub_any_instance(name, val_or_callable) 14 | new_name = "__minitest_any_instance_stub__#{name}" 15 | 16 | class_eval do 17 | alias_method new_name, name 18 | 19 | define_method(name) do |*args| 20 | if val_or_callable.respond_to?(:call) 21 | instance_exec(*args, &val_or_callable) 22 | else 23 | val_or_callable 24 | end 25 | end 26 | end 27 | 28 | yield 29 | ensure 30 | class_eval do 31 | undef_method name 32 | alias_method name, new_name 33 | undef_method new_name 34 | end 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /test/support/utilities.rb: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | def configure! 3 | before do 4 | Salesforce.configure! 5 | end 6 | 7 | after do 8 | DatabaseCleaner.clean 9 | Salesforce.clean! 10 | 11 | Restforce::DB::Registry.clean! 12 | Restforce::DB.reset! 13 | end 14 | end 15 | 16 | # :nodoc: 17 | def mappings! 18 | let(:database_model) { CustomObject } 19 | let(:salesforce_model) { "CustomObject__c" } 20 | let(:fields) { { name: "Name", example: "Example_Field__c" } } 21 | let(:conditions) { [] } 22 | let(:mapping) do 23 | Restforce::DB::Mapping.new(database_model, salesforce_model).tap do |map| 24 | map.conditions = conditions 25 | map.fields = fields 26 | end 27 | end 28 | end 29 | 30 | # :nodoc: 31 | def boolean_adapter 32 | Object.new.tap do |object| 33 | 34 | def object.to_database(attributes) 35 | attributes.each_with_object({}) do |(k, v), final| 36 | final[k] = (v == "Yes") 37 | end 38 | end 39 | 40 | def object.from_database(attributes) 41 | attributes.each_with_object({}) do |(k, v), final| 42 | final[k] = v ? "Yes" : "No" 43 | end 44 | end 45 | 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/vcr.rb: -------------------------------------------------------------------------------- 1 | require "minitest-vcr" 2 | require "vcr" 3 | require "webmock" 4 | 5 | VCR.configure do |c| 6 | c.cassette_library_dir = "test/cassettes" 7 | c.hook_into :webmock 8 | 9 | %w( 10 | username 11 | password 12 | security_token 13 | client_id 14 | client_secret 15 | host 16 | ).each do |secret| 17 | c.filter_sensitive_data("<#{secret}>") do 18 | CGI.escape(Secrets["client"][secret]) 19 | end 20 | end 21 | 22 | c.filter_sensitive_data("") do 23 | api_version = Secrets["client"].fetch("api_version") do 24 | Restforce::DB::Configuration::DEFAULT_API_VERSION 25 | end 26 | 27 | "v#{api_version}" 28 | end 29 | end 30 | 31 | MinitestVcr::Spec.configure! 32 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "cgi" 2 | require "minitest/autorun" 3 | require "yaml" 4 | 5 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 6 | require "restforce/db" 7 | 8 | secrets_file = File.expand_path("../config/secrets.yml", __FILE__) 9 | Secrets = YAML.load_file(secrets_file) 10 | 11 | Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each { |f| require f } 12 | 13 | require "minitest/spec/expect" 14 | --------------------------------------------------------------------------------