restforce-db 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 36ac23672709a2d24f9193d4d8cabbaf0bcb4e35
4
- data.tar.gz: 8d9f0bbeab71f7a1edf1c6159887f63b27865708
3
+ metadata.gz: 98a9d9a418a15a7b4ca5ab954bc95d3e831e3bca
4
+ data.tar.gz: 26effab90549afaecad3d7883ef6f0881c4e96d4
5
5
  SHA512:
6
- metadata.gz: ff1d1b1d71a0a506dbae3b3aa9990474c2e9800bef66987040d9df20b790bc2eb6720330cbf69fc71c1fd3ef2f321ae16f0607bacb1648920c5f7decd7c2a698
7
- data.tar.gz: 0152fb60d9d3a91ff76943c7be5cf64f480c365b46cd768bfbb224b9ac2c4f965db913f51b9a850134e653440f7df2ec755c31108a32132160fa2b6690e5f311
6
+ metadata.gz: 5b8140eb2811c5fad0e94d82bf255219eb15d95105581eb32d84ce3cf0fd504495c5b39251d436581eee8f1955355311484491b26571b0041cb38c50f0a2e269
7
+ data.tar.gz: 170e5593fe2cf93d1e2500927baaec31747d5670fd7bb4113619d6ba65e35fdabcbc48201cdc7213a696ea565772218111f95575945c3ad62e7b5554dda852c9
data/README.md CHANGED
@@ -24,11 +24,13 @@ This gem assumes that you're running Rails 4 or greater, therefore the `bin` fil
24
24
 
25
25
  ### Update your model schema
26
26
 
27
- In order to keep your database records in sync with Salesforce, the table will need to store a reference to its associated Salesforce record. A generator is included to trivially add this `salesforce_id` column to your tables:
27
+ In order to keep your database records in sync with Salesforce, the table will need to store a reference to its associated Salesforce record. A generator is included to trivially add a generic `salesforce_id` column to your tables:
28
28
 
29
29
  $ bundle exec rails g restforce:migration MyModel
30
30
  $ bundle exec rake db:migrate
31
31
 
32
+ If you need to activate multiple Salesforce mappings within a single model, you can do this with scoped column names. For example, if your Salesforce object types are named "Animal__c" and "Cat__c", `Restforce::DB` will look for columns named `animal_salesforce_id` and `cat_salesforce_id`.
33
+
32
34
  ### Register a mapping
33
35
 
34
36
  To register a Salesforce mapping in an `ActiveRecord` model, you'll need to add a few lines of DSL-style code to the relevant class definitions:
@@ -48,7 +50,6 @@ class Restaurant < ActiveRecord::Base
48
50
  associations: {
49
51
  specialty: "Specialty__c",
50
52
  },
51
- root: true,
52
53
  )
53
54
 
54
55
  end
@@ -60,6 +61,7 @@ class Dish < ActiveRecord::Base
60
61
 
61
62
  sync_with(
62
63
  "Dish__c",
64
+ through: "Specialty__c",
63
65
  fields: {
64
66
  name: "Name",
65
67
  ingredient: "Ingredient__c",
@@ -74,7 +76,7 @@ There are a few options to be aware of when describing a mapping:
74
76
 
75
77
  - `fields`: These are direct field-to-field mappings. The keys should line up with your ActiveRecord attribute names, while the values should line up with the matching field names in Salesforce.
76
78
  - `associations`: These are mappings of ActiveRecord associations to Salesforce lookups. Associations defined here will be built as part of the creation process for a newly-synced record.
77
- - `root`: When `true`, all records of the associated type will be synchronized from Salesforce into the database and vice-versa. When `false` or `nil`, records will still be updated, but can only be created through associations (see just above).
79
+ - `through`: This should be set for models which are created through an association. It references the lookup field on its parent's Salesforce object type.
78
80
 
79
81
  ### Run the daemon
80
82
 
@@ -0,0 +1,68 @@
1
+ module Restforce
2
+
3
+ module DB
4
+
5
+ module Associations
6
+
7
+ # Restforce::DB::Associations::ActiveRecord is a utility class which
8
+ # encapsulates the logic for creating/populating a one-to-one association
9
+ #
10
+ class ActiveRecord
11
+
12
+ attr_reader :associated
13
+
14
+ # Public: Initialize a new Restforce::DB::Associations::ActiveRecord.
15
+ #
16
+ # record - The base ActiveRecord::Base instance for which the
17
+ # association should be built.
18
+ # association - The name of the association which should be built.
19
+ def initialize(record, association)
20
+ @record = record
21
+ @associated = record.association(association).build
22
+ end
23
+
24
+ # Public: Build the associated record from the attributes found on the
25
+ # passed Salesforce record's lookups.
26
+ #
27
+ # from_record - A Hashie::Mash representing a base Salesforce record.
28
+ #
29
+ # Returns the constructed associated record.
30
+ def build(from_record)
31
+ Mapping[associated.class].each do |mapping|
32
+ lookup_id = from_record[mapping.through]
33
+ apply(mapping, lookup_id)
34
+ end
35
+
36
+ associated
37
+ end
38
+
39
+ private
40
+
41
+ # Internal: Assemble the associated record, using the data from the
42
+ # Salesforce record corresponding to a specific lookup ID.
43
+ #
44
+ # TODO: With some refactoring, this should be possible to handle as a
45
+ # recursive call to the configured Mapping's database record type. Right
46
+ # now, nested associations are unhandled.
47
+ #
48
+ # mapping - A Restforce::DB::Mapping.
49
+ # lookup_id - A Salesforce ID corresponding to the record type in the
50
+ # passed Mapping.
51
+ #
52
+ # Returns nothing.
53
+ def apply(mapping, lookup_id)
54
+ return if lookup_id.nil?
55
+
56
+ salesforce_instance = mapping.salesforce_record_type.find(lookup_id)
57
+ attributes = mapping.convert(associated.class, salesforce_instance.attributes)
58
+
59
+ associated.assign_attributes(attributes.merge(mapping.lookup_column => lookup_id))
60
+ end
61
+
62
+ end
63
+
64
+ end
65
+
66
+ end
67
+
68
+ end
@@ -13,14 +13,14 @@ module Restforce
13
13
  #
14
14
  # Returns a String.
15
15
  def id
16
- @record.salesforce_id
16
+ @record.send(@mapping.lookup_column)
17
17
  end
18
18
 
19
19
  # Public: Has this record been synced to a Salesforce record?
20
20
  #
21
21
  # Returns a Boolean.
22
22
  def synced?
23
- @record.salesforce_id?
23
+ @record.send(:"#{@mapping.lookup_column}?")
24
24
  end
25
25
 
26
26
  # Public: Get the time of the last update to this record.
@@ -7,6 +7,8 @@ module Restforce
7
7
  # attributes from one to the other.
8
8
  class Mapping
9
9
 
10
+ class InvalidMappingError < StandardError; end
11
+
10
12
  class << self
11
13
 
12
14
  include Enumerable
@@ -27,8 +29,10 @@ module Restforce
27
29
  # Yields one Mapping for each database-to-Salesforce mapping.
28
30
  # Returns nothing.
29
31
  def each
30
- collection.each do |database_model, record_type|
31
- yield database_model.name, record_type
32
+ collection.each do |_, mappings|
33
+ mappings.each do |mapping|
34
+ yield mapping
35
+ end
32
36
  end
33
37
  end
34
38
 
@@ -37,12 +41,14 @@ module Restforce
37
41
  self.collection ||= {}
38
42
 
39
43
  attr_reader(
44
+ :database_model,
45
+ :salesforce_model,
40
46
  :database_record_type,
41
47
  :salesforce_record_type,
42
48
  :associations,
43
49
  :conditions,
50
+ :through,
44
51
  )
45
- attr_writer :root
46
52
 
47
53
  # Public: Initialize a new Restforce::DB::Mapping.
48
54
  #
@@ -61,20 +67,24 @@ module Restforce
61
67
  # :root - A Boolean reflecting whether or not
62
68
  # this is a root-level mapping.
63
69
  def initialize(database_model, salesforce_model, options = {})
70
+ @database_model = database_model
71
+ @salesforce_model = salesforce_model
72
+
64
73
  @database_record_type = RecordTypes::ActiveRecord.new(database_model, self)
65
74
  @salesforce_record_type = RecordTypes::Salesforce.new(salesforce_model, self)
66
75
 
67
76
  @fields = options.fetch(:fields) { {} }
68
77
  @associations = options.fetch(:associations) { {} }
69
78
  @conditions = options.fetch(:conditions) { [] }
70
- @root = options.fetch(:root) { false }
79
+ @through = options.fetch(:through) { nil }
71
80
 
72
81
  @types = {
73
82
  database_model => :database,
74
83
  salesforce_model => :salesforce,
75
84
  }
76
85
 
77
- self.class.collection[database_model] = self
86
+ self.class.collection[database_model] ||= []
87
+ self.class.collection[database_model] << self
78
88
  end
79
89
 
80
90
  # Public: Get a list of the relevant Salesforce field names for this
@@ -82,7 +92,7 @@ module Restforce
82
92
  #
83
93
  # Returns an Array.
84
94
  def salesforce_fields
85
- @fields.values + @associations.values
95
+ @fields.values + @associations.values.flatten
86
96
  end
87
97
 
88
98
  # Public: Get a list of the relevant database column names for this
@@ -93,12 +103,32 @@ module Restforce
93
103
  @fields.keys
94
104
  end
95
105
 
106
+ # Public: Get the name of the database column which should be used to
107
+ # store the Salesforce lookup ID.
108
+ #
109
+ # Raises an InvalidMappingError if no database column exists.
110
+ # Returns a Symbol.
111
+ def lookup_column
112
+ @lookup_column ||= begin
113
+ column_prefix = salesforce_model.underscore.chomp("__c")
114
+ column = :"#{column_prefix}_salesforce_id"
115
+
116
+ if database_record_type.column?(column)
117
+ column
118
+ elsif database_record_type.column?(:salesforce_id)
119
+ :salesforce_id
120
+ else
121
+ raise InvalidMappingError, "#{database_model} must define a Salesforce ID column"
122
+ end
123
+ end
124
+ end
125
+
96
126
  # Public: Is this a root-level mapping? Used to determine whether or not
97
127
  # to trigger the creation of "missing" database records.
98
128
  #
99
129
  # Returns a Boolean.
100
130
  def root?
101
- @root
131
+ @through.nil?
102
132
  end
103
133
 
104
134
  # Public: Build a normalized Hash of attributes from the appropriate set
@@ -19,11 +19,10 @@ module Restforce
19
19
  def create!(from_record)
20
20
  attributes = @mapping.convert(@record_type, from_record.attributes)
21
21
 
22
- record = @record_type.new(attributes.merge(salesforce_id: from_record.id))
23
- associations = @mapping.associations.map do |association, lookup|
24
- associated = record.association(association).build
25
- lookup_id = from_record.record.send(lookup)
26
- build_association associated, lookup_id
22
+ record = @record_type.new(attributes.merge(@mapping.lookup_column => from_record.id))
23
+
24
+ associations = @mapping.associations.map do |association, _|
25
+ Associations::ActiveRecord.new(record, association).build(from_record.record)
27
26
  end
28
27
 
29
28
  record.transaction do
@@ -44,7 +43,7 @@ module Restforce
44
43
  #
45
44
  # Returns nil or a Restforce::DB::Instances::ActiveRecord instance.
46
45
  def find(id)
47
- record = @record_type.find_by(salesforce_id: id)
46
+ record = @record_type.find_by(@mapping.lookup_column => id)
48
47
  return nil unless record
49
48
 
50
49
  Instances::ActiveRecord.new(@record_type, record, @mapping)
@@ -72,6 +71,19 @@ module Restforce
72
71
  end
73
72
  end
74
73
 
74
+ # Public: Does the model represented by this record type have a column
75
+ # with the requested name?
76
+ #
77
+ # column - A Symbol column name.
78
+ #
79
+ # Returns a Boolean.
80
+ def column?(column)
81
+ ::ActiveRecord::Base.connection.column_exists?(
82
+ @record_type.table_name,
83
+ column,
84
+ )
85
+ end
86
+
75
87
  private
76
88
 
77
89
  # Internal: Has this Salesforce record already been linked to a database
@@ -81,32 +93,7 @@ module Restforce
81
93
  #
82
94
  # Returns a Boolean.
83
95
  def synced?(record)
84
- @record_type.exists?(salesforce_id: record.id)
85
- end
86
-
87
- # Internal: Assemble an associated record, using the data from the
88
- # Salesforce record corresponding to a specific lookup ID.
89
- #
90
- # TODO: With some refactoring using ActiveRecord inflections, this
91
- # should be possible to handle as a recursive call to the configured
92
- # Mapping's database record type. Right now, nested associations are
93
- # ignored.
94
- #
95
- # associated - The associated database record.
96
- # lookup_id - A Salesforce ID corresponding to the record type in the
97
- # Mapping defined for the associated database model.
98
- #
99
- # Returns the associated ActiveRecord instance.
100
- def build_association(associated, lookup_id)
101
- return if lookup_id.nil?
102
-
103
- mapping = Mapping[associated.class]
104
-
105
- salesforce_instance = mapping.salesforce_record_type.find(lookup_id)
106
- attributes = mapping.convert(associated.class, salesforce_instance.attributes)
107
-
108
- associated.assign_attributes(attributes.merge(salesforce_id: lookup_id))
109
- associated
96
+ @record_type.exists?(@mapping.lookup_column => record.id)
110
97
  end
111
98
 
112
99
  end
@@ -20,7 +20,7 @@ module Restforce
20
20
  attributes = @mapping.convert(@record_type, from_record.attributes)
21
21
  record_id = DB.client.create!(@record_type, attributes)
22
22
 
23
- from_record.update!(salesforce_id: record_id).after_sync
23
+ from_record.update!(@mapping.lookup_column => record_id).after_sync
24
24
 
25
25
  find(record_id)
26
26
  end
@@ -3,7 +3,7 @@ module Restforce
3
3
  # :nodoc:
4
4
  module DB
5
5
 
6
- VERSION = "0.3.5"
6
+ VERSION = "0.4.0"
7
7
 
8
8
  end
9
9
 
@@ -99,8 +99,8 @@ module Restforce
99
99
  # Returns nothing.
100
100
  def perform
101
101
  track do
102
- Restforce::DB::Mapping.each do |name, mapping|
103
- synchronize name, mapping
102
+ Restforce::DB::Mapping.each do |mapping|
103
+ synchronize mapping
104
104
  end
105
105
  end
106
106
  end
@@ -132,12 +132,11 @@ module Restforce
132
132
  # Internal: Synchronize the objects in the database and Salesforce
133
133
  # corresponding to the passed record type.
134
134
  #
135
- # name - The String name of the record type to synchronize.
136
135
  # mapping - A Restforce::DB::Mapping.
137
136
  #
138
137
  # Returns a Boolean.
139
- def synchronize(name, mapping)
140
- log " SYNCHRONIZE #{name}"
138
+ def synchronize(mapping)
139
+ log " SYNCHRONIZE #{mapping.database_model.name} with #{mapping.salesforce_model}"
141
140
  runtime = Benchmark.realtime { mapping.synchronizer.run(delay: @delay) }
142
141
  log format(" COMPLETE after %.4f", runtime)
143
142
 
data/lib/restforce/db.rb CHANGED
@@ -6,6 +6,8 @@ require "restforce/extensions"
6
6
  require "restforce/db/version"
7
7
  require "restforce/db/configuration"
8
8
 
9
+ require "restforce/db/associations/active_record"
10
+
9
11
  require "restforce/db/instances/base"
10
12
  require "restforce/db/instances/active_record"
11
13
  require "restforce/db/instances/salesforce"
@@ -0,0 +1,43 @@
1
+ require_relative "../../../../test_helper"
2
+
3
+ describe Restforce::DB::Associations::ActiveRecord do
4
+
5
+ configure!
6
+ mappings!
7
+
8
+ let(:associations) { { user: "Friend__c" } }
9
+ let(:record) { CustomObject.new }
10
+ let(:association) { Restforce::DB::Associations::ActiveRecord.new(record, :user) }
11
+
12
+ describe "#build" do
13
+ let(:association_id) { "a001a000001EFRIEND" }
14
+ let(:salesforce_record) { Hashie::Mash.new("Friend__c" => association_id) }
15
+ let(:associated_record) { association.build(salesforce_record) }
16
+
17
+ before do
18
+ mapping = Restforce::DB::Mapping.new(
19
+ User,
20
+ "Contact",
21
+ through: "Friend__c",
22
+ fields: { email: "Email" },
23
+ )
24
+ salesforce_record_type = mapping.salesforce_record_type
25
+
26
+ # Stub out the `#find` method on the record type
27
+ def salesforce_record_type.find(id)
28
+ Struct.new(:id, :last_update, :attributes).new(
29
+ id,
30
+ Time.now,
31
+ email: "somebody@example.com",
32
+ )
33
+ end
34
+ end
35
+
36
+ it "creates the associated record from the related Salesforce record's attributes" do
37
+ expect(associated_record).to_not_be_nil
38
+ expect(associated_record.email).to_equal("somebody@example.com")
39
+ expect(associated_record.salesforce_id).to_equal(association_id)
40
+ expect(associated_record).to_equal record.user
41
+ end
42
+ end
43
+ end
@@ -27,14 +27,14 @@ describe Restforce::DB::Mapping do
27
27
  # Restforce::DB::Mapping actually implements Enumerable, so we're just
28
28
  # going with a trivially testable portion of the Enumerable API.
29
29
  it "yields the registered record types" do
30
- expect(Restforce::DB::Mapping.first).to_equal [database_model.name, mapping]
30
+ expect(Restforce::DB::Mapping.first).to_equal mapping
31
31
  end
32
32
  end
33
33
 
34
34
  describe "#initialize" do
35
35
 
36
36
  it "adds the mapping to the global collection" do
37
- expect(Restforce::DB::Mapping[database_model]).to_equal mapping
37
+ expect(Restforce::DB::Mapping[database_model]).to_equal [mapping]
38
38
  end
39
39
  end
40
40
 
@@ -56,6 +56,46 @@ describe Restforce::DB::Mapping do
56
56
  end
57
57
  end
58
58
 
59
+ describe "#lookup_column" do
60
+ let(:db) { mapping.database_record_type }
61
+
62
+ describe "when the database table has a column matching the Salesforce model" do
63
+ before do
64
+ def db.column?(_)
65
+ true
66
+ end
67
+ end
68
+
69
+ it "returns the explicit column name" do
70
+ expect(mapping.lookup_column).to_equal(:custom_object_salesforce_id)
71
+ end
72
+ end
73
+
74
+ describe "when the database table has a generic salesforce ID column" do
75
+ before do
76
+ def db.column?(column)
77
+ column == :salesforce_id
78
+ end
79
+ end
80
+
81
+ it "returns the generic column name" do
82
+ expect(mapping.lookup_column).to_equal(:salesforce_id)
83
+ end
84
+ end
85
+
86
+ describe "when the database table has no salesforce ID column" do
87
+ before do
88
+ def db.column?(_)
89
+ false
90
+ end
91
+ end
92
+
93
+ it "raises an error" do
94
+ expect(-> { mapping.lookup_column }).to_raise Restforce::DB::Mapping::InvalidMappingError
95
+ end
96
+ end
97
+ end
98
+
59
99
  describe "#attributes" do
60
100
 
61
101
  it "builds a normalized Hash of database attribute values" do
@@ -23,8 +23,7 @@ describe Restforce::DB::Model do
23
23
  end
24
24
 
25
25
  it "creates a mapping in Restforce::DB::Mapping" do
26
- expect(Restforce::DB::Mapping[database_model])
27
- .to_be_instance_of(Restforce::DB::Mapping)
26
+ expect(Restforce::DB::Mapping[database_model]).to_not_be :empty?
28
27
  end
29
28
  end
30
29
 
@@ -79,7 +79,12 @@ describe Restforce::DB::RecordTypes::ActiveRecord do
79
79
  let(:associations) { { user: "Friend__c" } }
80
80
 
81
81
  before do
82
- mapping = Restforce::DB::Mapping.new(User, "Contact", fields: { email: "Email" })
82
+ mapping = Restforce::DB::Mapping.new(
83
+ User,
84
+ "Contact",
85
+ through: "Friend__c",
86
+ fields: { email: "Email" },
87
+ )
83
88
  salesforce_record_type = mapping.salesforce_record_type
84
89
 
85
90
  # Stub out the `#find` method on the record type
@@ -50,8 +50,9 @@ describe Restforce::DB::Synchronizer do
50
50
  end
51
51
 
52
52
  describe "for a non-root mapping" do
53
+ let(:through) { "SomeField__c" }
54
+
53
55
  before do
54
- mapping.root = false
55
56
  synchronizer.run
56
57
  end
57
58
 
@@ -18,11 +18,12 @@ def mappings!
18
18
  let(:fields) { { name: "Name", example: "Example_Field__c" } }
19
19
  let(:associations) { {} }
20
20
  let(:conditions) { [] }
21
+ let(:through) { nil }
21
22
  let!(:mapping) do
22
23
  Restforce::DB::Mapping.new(
23
24
  database_model,
24
25
  salesforce_model,
25
- root: true,
26
+ through: through,
26
27
  fields: fields,
27
28
  associations: associations,
28
29
  conditions: conditions,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: restforce-db
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Horner
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2015-04-01 00:00:00.000000000 Z
11
+ date: 2015-04-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -220,6 +220,7 @@ files:
220
220
  - lib/generators/templates/migration.rb.tt
221
221
  - lib/generators/templates/script
222
222
  - lib/restforce/db.rb
223
+ - lib/restforce/db/associations/active_record.rb
223
224
  - lib/restforce/db/command.rb
224
225
  - lib/restforce/db/configuration.rb
225
226
  - lib/restforce/db/instances/active_record.rb
@@ -252,6 +253,7 @@ files:
252
253
  - test/cassettes/Restforce_DB_Synchronizer/_run/given_an_existing_Salesforce_record/for_a_non-root_mapping/does_not_create_a_database_record.yml
253
254
  - test/cassettes/Restforce_DB_Synchronizer/_run/given_an_existing_Salesforce_record/for_a_root_mapping/creates_a_matching_database_record.yml
254
255
  - test/cassettes/Restforce_DB_Synchronizer/_run/given_an_existing_database_record/populates_Salesforce_with_the_new_record.yml
256
+ - test/lib/restforce/db/associations/active_record_test.rb
255
257
  - test/lib/restforce/db/configuration_test.rb
256
258
  - test/lib/restforce/db/instances/active_record_test.rb
257
259
  - test/lib/restforce/db/instances/salesforce_test.rb