restforce-db 0.3.5 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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