dm-salesforce-adapter 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.markdown +121 -0
- data/Rakefile +65 -0
- data/lib/dm-salesforce-adapter.rb +29 -0
- data/lib/dm-salesforce-adapter/adapter.rb +172 -0
- data/lib/dm-salesforce-adapter/connection.rb +144 -0
- data/lib/dm-salesforce-adapter/connection/errors.rb +50 -0
- data/lib/dm-salesforce-adapter/property.rb +7 -0
- data/lib/dm-salesforce-adapter/property/boolean.rb +17 -0
- data/lib/dm-salesforce-adapter/property/serial.rb +12 -0
- data/lib/dm-salesforce-adapter/resource.rb +8 -0
- data/lib/dm-salesforce-adapter/soap_wrapper.rb +82 -0
- data/lib/dm-salesforce-adapter/sql.rb +99 -0
- data/lib/dm-salesforce-adapter/version.rb +3 -0
- metadata +180 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2008 Yehuda Katz
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
dm-salesforce-adapter
|
2
|
+
=====================
|
3
|
+
|
4
|
+
This gem provides a Salesforce Adapter under DataMapper 1.0.x. A version of
|
5
|
+
this adapter supporting DM 0.10.x exists under the name "dm-salesforce". Older
|
6
|
+
versions of DM (0.9.x) are no longer supported.
|
7
|
+
|
8
|
+
What it looks like
|
9
|
+
==================
|
10
|
+
|
11
|
+
class Account
|
12
|
+
include DataMapper::Salesforce::Resource
|
13
|
+
|
14
|
+
def self.default_repository_name
|
15
|
+
:salesforce
|
16
|
+
end
|
17
|
+
|
18
|
+
property :id, Serial
|
19
|
+
property :name, String
|
20
|
+
property :description, String
|
21
|
+
property :fax, String
|
22
|
+
property :phone, String
|
23
|
+
property :type, String
|
24
|
+
property :website, String
|
25
|
+
property :is_awesome, Boolean
|
26
|
+
|
27
|
+
has 0..n, :contacts
|
28
|
+
end
|
29
|
+
|
30
|
+
class Contact
|
31
|
+
include DataMapper::Salesforce::Resource
|
32
|
+
|
33
|
+
def self.default_repository_name
|
34
|
+
:salesforce
|
35
|
+
end
|
36
|
+
|
37
|
+
property :id, Serial
|
38
|
+
property :first_name, String
|
39
|
+
property :last_name, String
|
40
|
+
property :email, String
|
41
|
+
|
42
|
+
belongs_to :account
|
43
|
+
end
|
44
|
+
|
45
|
+
DataMapper.setup(:salesforce, {:adapter => 'salesforce',
|
46
|
+
:username => 'salesforceuser@mydomain.com',
|
47
|
+
:password => 'skateboardsf938915c9cdc36ff5498881b',
|
48
|
+
:path => '/path/to/wsdl.xml',
|
49
|
+
:host => ''})
|
50
|
+
|
51
|
+
account = Account.first
|
52
|
+
account.is_awesome = true
|
53
|
+
account.save
|
54
|
+
|
55
|
+
See [the fixtures](http://github.com/cloudcrowd/dm-salesforce-adapter/tree/master/spec/fixtures) for more examples.
|
56
|
+
|
57
|
+
How it works
|
58
|
+
============
|
59
|
+
|
60
|
+
Salesforce provides an XML-based WSDL definition of an existing schema/object
|
61
|
+
model for download. dm-salesforce-adapter uses this WSDL to auto-generate a
|
62
|
+
SOAP-based Ruby driver and classes, which is then used to implement a basic,
|
63
|
+
low-level DataMapper Adapter.
|
64
|
+
|
65
|
+
Upon first access, the driver and classes are cached locally on disk in one of
|
66
|
+
the following locations (in order of precedence):
|
67
|
+
|
68
|
+
* In `apidir`, defined in `database.yml` (see included database.yml-example)
|
69
|
+
* In `ENV['SALESFORCE_DIR']`
|
70
|
+
* In `ENV['HOME']/.salesforce/`
|
71
|
+
|
72
|
+
Getting set up
|
73
|
+
==============
|
74
|
+
|
75
|
+
1. Obtain a working salesforce.com account
|
76
|
+
|
77
|
+
2. Get a valid security token (if you don't already have one)
|
78
|
+
* Login to `https://login.salesforce.com`
|
79
|
+
* [Click "Setup"][setup]
|
80
|
+
* [Click "Personal Setup" / "My Personal Information" / "Reset My Security Token"][gettoken]
|
81
|
+
* This will send a message to your account's email address with an "API key"
|
82
|
+
(looks like a 24 character token)
|
83
|
+
|
84
|
+
3. Get the Enterprise WSDL for your object model
|
85
|
+
* Login to `https://login.salesforce.com`
|
86
|
+
* [Click "Setup"][setup]
|
87
|
+
* [Click "App Setup" / "Develop" / "API"][getwsdl]
|
88
|
+
* Click "Generate Enterprise WSDL", then click the "Generate" button
|
89
|
+
* Save that to an .xml file somewhere (path/extension doesn't matter - you specify it
|
90
|
+
in database.yml / DataMapper.setup)
|
91
|
+
|
92
|
+
4. Copy and modify config/example.rb to use your info
|
93
|
+
* The :password field is the concatenation of your login password and the API key
|
94
|
+
* If your password is 'skateboards' and API key is 'f938915c9cdc36ff5498881b', then
|
95
|
+
the :password field you specify to DataMapper.setup should be
|
96
|
+
'skateboardsf938915c9cdc36ff5498881b'
|
97
|
+
|
98
|
+
Run 'ruby example.rb' and you should have access to the Account and Contact models (schema
|
99
|
+
differences withstanding).
|
100
|
+
|
101
|
+
**Don't forget to:**
|
102
|
+
|
103
|
+
* Retrieve a new copy of your WSDL anytime you make changes to your Salesforce schema
|
104
|
+
* Wipe the auto-generated SOAP classes anytime you update your WSDL
|
105
|
+
|
106
|
+
|
107
|
+
Special Thanks to those who helped
|
108
|
+
==================================================
|
109
|
+
* Yehuda Katz
|
110
|
+
* Corey Donohoe
|
111
|
+
* Tim Carey-Smith
|
112
|
+
* Andy Delcambre
|
113
|
+
* Ben Burkert
|
114
|
+
* Larry Diehl
|
115
|
+
* Jordan Ritter
|
116
|
+
* Martin Emde
|
117
|
+
* Jason Snell
|
118
|
+
|
119
|
+
[setup]: http://img.skitch.com/20090204-gaxdfxbi1emfita5dax48ids4m.jpg "Click on Setup"
|
120
|
+
[getwsdl]: http://img.skitch.com/20090204-nhurnuxwf5g3ufnjk2xkfjc5n4.jpg "Expand and Save"
|
121
|
+
[gettoken]: http://img.skitch.com/20090204-mnt182ce7bc4seecqbrjjxjbef.jpg "You can reset your token here"
|
data/Rakefile
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'rake/gempackagetask'
|
2
|
+
require 'rubygems/specification'
|
3
|
+
require 'date'
|
4
|
+
require 'pp'
|
5
|
+
require 'tmpdir'
|
6
|
+
|
7
|
+
require 'bundler/setup'
|
8
|
+
|
9
|
+
Bundler.require
|
10
|
+
|
11
|
+
task :default => 'spec'
|
12
|
+
require 'spec'
|
13
|
+
require 'spec/rake/spectask'
|
14
|
+
desc "Run specs"
|
15
|
+
Spec::Rake::SpecTask.new(:spec) do |t|
|
16
|
+
t.spec_opts << %w(-fs --color) << %w(-O spec/spec.opts)
|
17
|
+
t.spec_opts << '--loadby' << 'random'
|
18
|
+
t.spec_files = Dir["spec/**/*_spec.rb"]
|
19
|
+
t.rcov = ENV.has_key?('NO_RCOV') ? ENV['NO_RCOV'] != 'true' : true
|
20
|
+
t.rcov_opts << '--exclude' << "~/.salesforce,gems,vendor,/var/folders,spec,config,tmp"
|
21
|
+
t.rcov_opts << '--text-summary'
|
22
|
+
t.rcov_opts << '--sort' << 'coverage' << '--sort-reverse'
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "Release the version"
|
26
|
+
task :release => :repackage do
|
27
|
+
version = SalesforceAdapter::VERSION
|
28
|
+
puts "Releasing #{version}"
|
29
|
+
|
30
|
+
`git show-ref tags/v#{version}`
|
31
|
+
unless $?.success?
|
32
|
+
abort "There is no tag for v#{version}"
|
33
|
+
end
|
34
|
+
|
35
|
+
`git show-ref heads/releasing`
|
36
|
+
if $?.success?
|
37
|
+
abort "Remove the releasing branch, we need it!"
|
38
|
+
end
|
39
|
+
|
40
|
+
puts "Checking out to the releasing branch as the tag"
|
41
|
+
system("git", "checkout", "-b", "releasing", "tags/v#{version}")
|
42
|
+
|
43
|
+
puts "Reseting back to master"
|
44
|
+
system("git", "checkout", "master")
|
45
|
+
system("git", "branch", "-d", "releasing")
|
46
|
+
|
47
|
+
current = @spec.version.to_s + ".0"
|
48
|
+
next_version = Gem::Version.new(current).bump
|
49
|
+
|
50
|
+
puts "Changing the version to #{next_version}."
|
51
|
+
|
52
|
+
version_file = File.dirname(__FILE__)+"/lib/#{GEM}/version.rb"
|
53
|
+
File.open(version_file, "w") do |f|
|
54
|
+
f.puts <<-EOT
|
55
|
+
module SalesforceAdapter
|
56
|
+
VERSION = "#{next_version}"
|
57
|
+
end
|
58
|
+
EOT
|
59
|
+
end
|
60
|
+
|
61
|
+
puts "Committing the version change"
|
62
|
+
system("git", "commit", version_file, "-m", "Next version: #{next_version}")
|
63
|
+
|
64
|
+
puts "Push the commit up! if you don't, you'll be hunted down"
|
65
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'data_objects'
|
2
|
+
require 'dm-core'
|
3
|
+
require 'dm-types'
|
4
|
+
require 'dm-validations'
|
5
|
+
|
6
|
+
require 'soap/wsdlDriver'
|
7
|
+
require 'soap/header/simplehandler'
|
8
|
+
require 'rexml/element'
|
9
|
+
|
10
|
+
class SalesforceAdapter < ::DataMapper::Adapters::AbstractAdapter
|
11
|
+
Inflector = ::DataMapper.const_defined?(:Inflector) ? ::DataMapper::Inflector : ::Extlib::Inflection
|
12
|
+
end
|
13
|
+
|
14
|
+
require 'dm-salesforce-adapter/resource'
|
15
|
+
require 'dm-salesforce-adapter/connection'
|
16
|
+
require 'dm-salesforce-adapter/connection/errors'
|
17
|
+
require 'dm-salesforce-adapter/soap_wrapper'
|
18
|
+
require 'dm-salesforce-adapter/sql'
|
19
|
+
require 'dm-salesforce-adapter/version'
|
20
|
+
require 'dm-salesforce-adapter/adapter'
|
21
|
+
require 'dm-salesforce-adapter/property'
|
22
|
+
|
23
|
+
# For convenience (WRT the examples)
|
24
|
+
module DataMapper::Salesforce
|
25
|
+
Resource = SalesforceAdapter::Resource
|
26
|
+
end
|
27
|
+
|
28
|
+
::DataMapper::Adapters::SalesforceAdapter = SalesforceAdapter
|
29
|
+
::DataMapper::Adapters.const_added(:SalesforceAdapter)
|
@@ -0,0 +1,172 @@
|
|
1
|
+
|
2
|
+
class SalesforceAdapter
|
3
|
+
include SQL
|
4
|
+
|
5
|
+
def initialize(name, uri_or_options)
|
6
|
+
super
|
7
|
+
@resource_naming_convention = proc do |value|
|
8
|
+
klass = Inflector.constantize(value)
|
9
|
+
if klass.respond_to?(:salesforce_class)
|
10
|
+
klass.salesforce_class
|
11
|
+
else
|
12
|
+
value.split("::").last
|
13
|
+
end
|
14
|
+
end
|
15
|
+
@field_naming_convention = proc do |property|
|
16
|
+
connection.field_name_for(property.model.storage_name(name), property.name.to_s)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def connection
|
21
|
+
@connection ||= Connection.new(options["username"], options["password"], options["path"], options["apidir"])
|
22
|
+
end
|
23
|
+
|
24
|
+
# FIXME: DM Adapters customarily throw exceptions when they experience errors,
|
25
|
+
# otherwise failed operations (e.g. Resource#save) still return true and thus
|
26
|
+
# confuse the caller.
|
27
|
+
#
|
28
|
+
# Someone needs to make a decision about legacy support and the consequences
|
29
|
+
# of changing the behaviour from broken-but-typical to
|
30
|
+
# correct-but-maybe-unexpected. Maybe a config file option about whether to
|
31
|
+
# raise exceptions or for the user to always check Model#valid? +
|
32
|
+
# Model#salesforce_errors?
|
33
|
+
#
|
34
|
+
# Needs to be applied to all CRUD operations.
|
35
|
+
def create(resources)
|
36
|
+
arr = resources.map do |resource|
|
37
|
+
make_salesforce_obj(resource, resource.dirty_attributes)
|
38
|
+
end
|
39
|
+
|
40
|
+
result = connection.create(arr)
|
41
|
+
result.each_with_index do |record, i|
|
42
|
+
resource = resources[i]
|
43
|
+
if id_field = resource.class.key.find {|p| p.serial?}
|
44
|
+
normalized_value = normalize_id_value(resource.class, id_field, record.id)
|
45
|
+
id_field.set!(resource, normalized_value)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
result.size
|
50
|
+
|
51
|
+
rescue Connection::SOAPError => e
|
52
|
+
handle_server_outage(e)
|
53
|
+
end
|
54
|
+
|
55
|
+
def update(attributes, collection)
|
56
|
+
query = collection.query
|
57
|
+
arr = collection.map { |obj| make_salesforce_obj(query, attributes) }
|
58
|
+
|
59
|
+
connection.update(arr).size
|
60
|
+
|
61
|
+
rescue Connection::SOAPError => e
|
62
|
+
handle_server_outage(e)
|
63
|
+
end
|
64
|
+
|
65
|
+
def delete(collection)
|
66
|
+
query = collection.query
|
67
|
+
keys = collection.map { |r| r.key }.flatten.uniq
|
68
|
+
|
69
|
+
connection.delete(keys).size
|
70
|
+
|
71
|
+
rescue Connection::SOAPError => e
|
72
|
+
handle_server_outage(e)
|
73
|
+
end
|
74
|
+
|
75
|
+
def handle_server_outage(error)
|
76
|
+
if error.server_unavailable?
|
77
|
+
raise Connection::ServerUnavailable, "The salesforce server is currently unavailable"
|
78
|
+
else
|
79
|
+
raise error
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Reading responses back from SELECTS:
|
84
|
+
# In the typical case, response.size reflects the # of records returned.
|
85
|
+
# In the aggregation case, response.size reflects the count.
|
86
|
+
#
|
87
|
+
# Interpretation of this field requires knowledge of whether we are expecting
|
88
|
+
# an aggregate result, thus the response from execute_select() is processed
|
89
|
+
# differently depending on invocation (read vs. aggregate).
|
90
|
+
def read(query)
|
91
|
+
properties = query.fields
|
92
|
+
repository = query.repository
|
93
|
+
|
94
|
+
response = execute_select(query)
|
95
|
+
return [] unless response.records
|
96
|
+
|
97
|
+
rows = response.records.inject([]) do |results, record|
|
98
|
+
results << properties.inject({}) do |result, property|
|
99
|
+
meth = connection.field_name_for(property.model.storage_name(repository.name), property.field)
|
100
|
+
result[property] = normalize_id_value(query.model, property, record.send(meth))
|
101
|
+
result
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
query.model.load(rows, query)
|
106
|
+
end
|
107
|
+
|
108
|
+
# http://www.salesforce.com/us/developer/docs/api90/Content/sforce_api_calls_soql.htm
|
109
|
+
# SOQL doesn't support anything but count(), so we catch it here and interpret
|
110
|
+
# the result. Requires 'dm-aggregates' to be loaded.
|
111
|
+
def aggregate(query)
|
112
|
+
query.fields.each do |f|
|
113
|
+
unless f.target == :all && f.operator == :count
|
114
|
+
raise ArgumentError, %{Aggregate function #{f.operator} not supported in SOQL}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
[ execute_select(query).size ]
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
def execute_select(query)
|
123
|
+
repository = query.repository
|
124
|
+
conditions = query.conditions.map {|c| conditions_statement(c, repository)}.compact.join(") AND (")
|
125
|
+
|
126
|
+
fields = query.fields.map do |f|
|
127
|
+
case f
|
128
|
+
when DataMapper::Property
|
129
|
+
f.field
|
130
|
+
when DataMapper::Query::Operator
|
131
|
+
%{#{f.operator}()}
|
132
|
+
else
|
133
|
+
raise ArgumentError, "Unknown query field #{f.class}: #{f.inspect}"
|
134
|
+
end
|
135
|
+
end.join(", ")
|
136
|
+
|
137
|
+
sql = "SELECT #{fields} from #{query.model.storage_name(repository.name)}"
|
138
|
+
sql << " WHERE (#{conditions})" unless conditions.empty?
|
139
|
+
sql << " ORDER BY #{order(query.order[0])}" unless query.order.empty?
|
140
|
+
sql << " LIMIT #{query.limit}" if query.limit
|
141
|
+
|
142
|
+
DataMapper.logger.debug sql if DataMapper.logger
|
143
|
+
|
144
|
+
connection.query(sql)
|
145
|
+
end
|
146
|
+
|
147
|
+
def make_salesforce_obj(from, with_attrs)
|
148
|
+
klass_name = from.model.storage_name(from.repository.name)
|
149
|
+
values = {}
|
150
|
+
|
151
|
+
# FIXME: query.conditions is potentially a tree now
|
152
|
+
if from.is_a?(::DataMapper::Query)
|
153
|
+
key_value = from.conditions.find { |c| c.subject.key? }.value
|
154
|
+
values["id"] = normalize_id_value(from.model, from.model.properties[:id], key_value)
|
155
|
+
end
|
156
|
+
|
157
|
+
with_attrs.each do |property, value|
|
158
|
+
next if property.serial? || property.key? and value.nil?
|
159
|
+
values[property.field] = normalize_id_value(from.model, property, value)
|
160
|
+
end
|
161
|
+
|
162
|
+
connection.make_object(klass_name, values)
|
163
|
+
end
|
164
|
+
|
165
|
+
def normalize_id_value(klass, property, value)
|
166
|
+
return nil unless value
|
167
|
+
properties = Array(klass.send(:salesforce_id_properties)).map { |p| p.to_sym } rescue []
|
168
|
+
return properties.include?(property.name) ? value[0..14] : value
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
|
@@ -0,0 +1,144 @@
|
|
1
|
+
require 'dm-salesforce-adapter/connection/errors'
|
2
|
+
|
3
|
+
class SalesforceAdapter
|
4
|
+
class Connection
|
5
|
+
include Errors
|
6
|
+
|
7
|
+
class HeaderHandler < SOAP::Header::SimpleHandler
|
8
|
+
def initialize(tag, value)
|
9
|
+
super(XSD::QName.new('urn:enterprise.soap.sforce.com', tag))
|
10
|
+
@tag = tag
|
11
|
+
@value = value
|
12
|
+
end
|
13
|
+
def on_simple_outbound
|
14
|
+
@value
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(username, password, wsdl_path, api_dir, organization_id = nil)
|
19
|
+
@wrapper = SoapWrapper.new("SalesforceAPI", "Soap", wsdl_path, api_dir)
|
20
|
+
@username, @password, @organization_id = URI.unescape(username), password, organization_id
|
21
|
+
login
|
22
|
+
end
|
23
|
+
attr_reader :user_id, :user_details
|
24
|
+
|
25
|
+
def wsdl_path
|
26
|
+
@wrapper.wsdl_path
|
27
|
+
end
|
28
|
+
|
29
|
+
def api_dir
|
30
|
+
@wrapper.api_dir
|
31
|
+
end
|
32
|
+
|
33
|
+
def organization_id
|
34
|
+
@user_details && @user_details.organizationId
|
35
|
+
end
|
36
|
+
|
37
|
+
def make_object(klass_name, values)
|
38
|
+
obj = SalesforceAPI.const_get(klass_name).new
|
39
|
+
values.each do |property, value|
|
40
|
+
field = field_name_for(klass_name, property)
|
41
|
+
if value.nil? or value == ""
|
42
|
+
obj.fieldsToNull.push(field)
|
43
|
+
else
|
44
|
+
obj.send("#{field}=", value)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
obj
|
48
|
+
end
|
49
|
+
|
50
|
+
def field_name_for(klass_name, column)
|
51
|
+
klass = SalesforceAPI.const_get(klass_name)
|
52
|
+
# extlib vs. activesupport
|
53
|
+
fields = [column, (column.camel_case rescue column.camelcase), "#{column}__c".downcase]
|
54
|
+
options = /^(#{fields.join("|")})$/i
|
55
|
+
matches = klass.instance_methods(false).grep(options)
|
56
|
+
if matches.any?
|
57
|
+
matches.first
|
58
|
+
else
|
59
|
+
raise FieldNotFound,
|
60
|
+
"You specified #{column} as a field, but neither #{fields.join(" or ")} exist. " \
|
61
|
+
"Either manually specify the field name with :field, or check to make sure you have " \
|
62
|
+
"provided a correct field name."
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def query(string)
|
67
|
+
with_reconnection do
|
68
|
+
driver.query(:queryString => string).result
|
69
|
+
end
|
70
|
+
rescue SOAP::FaultError => e
|
71
|
+
raise QueryError.new(e.message, [])
|
72
|
+
end
|
73
|
+
|
74
|
+
def create(objects)
|
75
|
+
call_api(:create, CreateError, "creating", objects)
|
76
|
+
end
|
77
|
+
|
78
|
+
def update(objects)
|
79
|
+
call_api(:update, UpdateError, "updating", objects)
|
80
|
+
end
|
81
|
+
|
82
|
+
def delete(keys)
|
83
|
+
call_api(:delete, DeleteError, "deleting", keys)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def driver
|
89
|
+
@wrapper.driver
|
90
|
+
end
|
91
|
+
|
92
|
+
def login
|
93
|
+
driver
|
94
|
+
if @organization_id
|
95
|
+
driver.headerhandler << HeaderHandler.new("LoginScopeHeader", :organizationId => @organization_id)
|
96
|
+
end
|
97
|
+
|
98
|
+
begin
|
99
|
+
result = driver.login(:username => @username, :password => @password).result
|
100
|
+
rescue SOAP::FaultError => error
|
101
|
+
if error.faultcode.to_s =~ /INVALID_LOGIN/
|
102
|
+
raise LoginFailed, error.faultstring.to_s
|
103
|
+
else
|
104
|
+
raise error
|
105
|
+
end
|
106
|
+
end
|
107
|
+
driver.endpoint_url = result.serverUrl
|
108
|
+
driver.headerhandler << HeaderHandler.new("SessionHeader", "sessionId" => result.sessionId)
|
109
|
+
driver.headerhandler << HeaderHandler.new("CallOptions", "client" => "client")
|
110
|
+
@user_id = result.userId
|
111
|
+
@user_details = result.userInfo
|
112
|
+
driver
|
113
|
+
end
|
114
|
+
|
115
|
+
def call_api(method, exception_class, message, args)
|
116
|
+
with_reconnection do
|
117
|
+
result = driver.send(method, args)
|
118
|
+
if result.all? {|r| r.success}
|
119
|
+
result
|
120
|
+
else
|
121
|
+
# TODO: be smarter about exceptions here
|
122
|
+
raise exception_class.new("Got some errors while #{message} Salesforce objects", result)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def with_reconnection(&block)
|
128
|
+
yield
|
129
|
+
rescue SOAP::FaultError => error
|
130
|
+
retry_count ||= 0
|
131
|
+
if error.faultcode.to_s =~ "INVALID_SESSION_ID"
|
132
|
+
DataMapper.logger.debug "Got a invalid session id; reconnecting" if DataMapper.logger
|
133
|
+
@driver = nil
|
134
|
+
login
|
135
|
+
retry_count += 1
|
136
|
+
retry unless retry_count > 5
|
137
|
+
else
|
138
|
+
raise error
|
139
|
+
end
|
140
|
+
|
141
|
+
raise SessionTimeout, "The Salesforce session could not be established"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class SalesforceAdapter::Connection
|
2
|
+
module Errors
|
3
|
+
class Error < StandardError; end
|
4
|
+
class FieldNotFound < Error; end
|
5
|
+
class LoginFailed < Error; end
|
6
|
+
class SessionTimeout < Error; end
|
7
|
+
class UnknownStatusCode < Error; end
|
8
|
+
class ServerUnavailable < Error; end
|
9
|
+
|
10
|
+
|
11
|
+
class SOAPError < Error
|
12
|
+
def initialize(message, result)
|
13
|
+
@result = result
|
14
|
+
super("#{message}: #{result_message}")
|
15
|
+
end
|
16
|
+
|
17
|
+
def records
|
18
|
+
@result.to_a
|
19
|
+
end
|
20
|
+
|
21
|
+
def failed_records
|
22
|
+
@result.reject {|r| r.success}
|
23
|
+
end
|
24
|
+
|
25
|
+
def successful_records
|
26
|
+
@result.select {|r| r.success}
|
27
|
+
end
|
28
|
+
|
29
|
+
def result_message
|
30
|
+
failed_records.map do |r|
|
31
|
+
message_for_record(r)
|
32
|
+
end.join("; ")
|
33
|
+
end
|
34
|
+
|
35
|
+
def message_for_record(record)
|
36
|
+
record.errors.map {|e| "#{e.statusCode}: #{e.message}"}.join(", ")
|
37
|
+
end
|
38
|
+
|
39
|
+
def server_unavailable?
|
40
|
+
failed_records.any? do |record|
|
41
|
+
record.errors.any? {|e| e.statusCode == "SERVER_UNAVAILABLE"}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
class CreateError < SOAPError; end
|
46
|
+
class QueryError < SOAPError; end
|
47
|
+
class DeleteError < SOAPError; end
|
48
|
+
class UpdateError < SOAPError; end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module SalesforceAdapter::Property
|
2
|
+
class Boolean < ::DataMapper::Property::Integer
|
3
|
+
FALSE = 0
|
4
|
+
TRUE = 1
|
5
|
+
|
6
|
+
def self.dump(value, property)
|
7
|
+
case value
|
8
|
+
when nil, false then FALSE
|
9
|
+
else TRUE
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.load(value, property)
|
14
|
+
[true, 1, '1', 'true', 'TRUE', TRUE].include?(value)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
class SalesforceAdapter
|
2
|
+
class SoapWrapper
|
3
|
+
class ClassesFailedToGenerate < StandardError; end
|
4
|
+
|
5
|
+
attr_reader :module_name, :driver_name, :wsdl_path, :api_dir
|
6
|
+
|
7
|
+
def initialize(module_name, driver_name, wsdl_path, api_dir)
|
8
|
+
@module_name, @driver_name, @wsdl_path, @api_dir = module_name, driver_name, File.expand_path(wsdl_path), File.expand_path(api_dir)
|
9
|
+
generate_soap_classes
|
10
|
+
driver
|
11
|
+
end
|
12
|
+
|
13
|
+
def driver
|
14
|
+
@driver ||= Object.const_get(module_name).const_get(driver_name).new
|
15
|
+
end
|
16
|
+
|
17
|
+
=begin
|
18
|
+
# Attempt at a run-time equivalent. Works except for the API responses are
|
19
|
+
# in SOAP objects, not native ruby objects. Haven't figured that one out..
|
20
|
+
def driver
|
21
|
+
return @driver if @driver
|
22
|
+
|
23
|
+
require 'wsdl/soap/wsdl2ruby'
|
24
|
+
|
25
|
+
factory = SOAP::WSDLDriverFactory.new(wsdl_path)
|
26
|
+
class_name_creator = WSDL::SOAP::ClassNameCreator.new
|
27
|
+
|
28
|
+
eval(WSDL::SOAP::ClassDefCreator.new(factory.wsdl, class_name_creator, @module_name).dump, TOPLEVEL_BINDING)
|
29
|
+
eval(WSDL::SOAP::MappingRegistryCreator.new(factory.wsdl, class_name_creator, @module_name).dump, TOPLEVEL_BINDING)
|
30
|
+
|
31
|
+
@driver ||= factory.create_rpc_driver
|
32
|
+
end
|
33
|
+
=end
|
34
|
+
|
35
|
+
def generate_soap_classes
|
36
|
+
unless File.file?(wsdl_path)
|
37
|
+
raise Errno::ENOENT, "Could not find the WSDL at #{wsdl_path}"
|
38
|
+
end
|
39
|
+
|
40
|
+
FileUtils.mkdir_p(wsdl_api_dir)
|
41
|
+
|
42
|
+
generate_files unless files_exist?
|
43
|
+
|
44
|
+
$:.push wsdl_api_dir
|
45
|
+
require "#{module_name}Driver"
|
46
|
+
$:.delete wsdl_api_dir
|
47
|
+
end
|
48
|
+
|
49
|
+
# Good candidate for shipping out into a Rakefile.
|
50
|
+
def generate_files
|
51
|
+
require 'wsdl/soap/wsdl2ruby'
|
52
|
+
|
53
|
+
wsdl2ruby = WSDL::SOAP::WSDL2Ruby.new
|
54
|
+
wsdl2ruby.logger = $LOG if $LOG
|
55
|
+
wsdl2ruby.location = wsdl_path
|
56
|
+
wsdl2ruby.basedir = wsdl_api_dir
|
57
|
+
|
58
|
+
wsdl2ruby.opt.merge!({
|
59
|
+
'classdef' => module_name,
|
60
|
+
'module_path' => module_name,
|
61
|
+
'mapping_registry' => nil,
|
62
|
+
'driver' => nil,
|
63
|
+
'client_skelton' => nil,
|
64
|
+
})
|
65
|
+
|
66
|
+
wsdl2ruby.run
|
67
|
+
|
68
|
+
raise ClassesFailedToGenerate unless files_exist?
|
69
|
+
end
|
70
|
+
|
71
|
+
def files_exist?
|
72
|
+
["#{module_name}.rb", "#{module_name}MappingRegistry.rb", "#{module_name}Driver.rb"].all? do |name|
|
73
|
+
File.exist?("#{wsdl_api_dir}/#{name}")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def wsdl_api_dir
|
78
|
+
"#{api_dir}/#{File.basename(wsdl_path)}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
@@ -0,0 +1,99 @@
|
|
1
|
+
class SalesforceAdapter
|
2
|
+
module SQL
|
3
|
+
def conditions_statement(conditions, repository)
|
4
|
+
case conditions
|
5
|
+
when DataMapper::Query::Conditions::NotOperation then negate_operation(conditions.operand, repository)
|
6
|
+
when DataMapper::Query::Conditions::AbstractOperation then conditions.operands.first # ignores AND/OR grouping for now.
|
7
|
+
when DataMapper::Query::Conditions::AbstractComparison then comparison_statement(conditions, repository)
|
8
|
+
else raise("Unkown condition type #{conditions.class}: #{conditions.inspect}")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def comparison_statement(comparison, repository)
|
13
|
+
subject = comparison.subject
|
14
|
+
value = comparison.value
|
15
|
+
|
16
|
+
if comparison.relationship?
|
17
|
+
return conditions_statement(comparison.foreign_key_mapping, repository)
|
18
|
+
elsif comparison.slug == :in && value.empty?
|
19
|
+
return [] # match everything
|
20
|
+
end
|
21
|
+
|
22
|
+
operator = comparison_operator(comparison)
|
23
|
+
column_name = property_to_column_name(subject, repository)
|
24
|
+
|
25
|
+
"#{column_name} #{operator} #{quote_value(value,subject)}"
|
26
|
+
end
|
27
|
+
|
28
|
+
def comparison_operator(comparison)
|
29
|
+
subject = comparison.subject
|
30
|
+
value = comparison.value
|
31
|
+
|
32
|
+
case comparison.slug
|
33
|
+
when :eql then equality_operator(subject, value)
|
34
|
+
when :in then include_operator(subject, value)
|
35
|
+
when :not then inequality_operator(subject, value)
|
36
|
+
when :regexp then regexp_operator(value)
|
37
|
+
when :like then like_operator(value)
|
38
|
+
when :gt then '>'
|
39
|
+
when :lt then '<'
|
40
|
+
when :gte then '>='
|
41
|
+
when :lte then '<='
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def negate_operation(operand, repository)
|
46
|
+
statement = conditions_statement(operand, repository)
|
47
|
+
statement = "NOT(#{statement})" unless statement.nil?
|
48
|
+
statement
|
49
|
+
end
|
50
|
+
|
51
|
+
def property_to_column_name(prop, repository)
|
52
|
+
case prop
|
53
|
+
when DataMapper::Property
|
54
|
+
prop.field
|
55
|
+
when DataMapper::Query::Path
|
56
|
+
rels = prop.relationships
|
57
|
+
names = rels.map {|r| storage_name(r, repository) }.join(".")
|
58
|
+
"#{names}.#{prop.field}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def storage_name(rel, repository)
|
63
|
+
rel.parent_model.storage_name(repository.name)
|
64
|
+
end
|
65
|
+
|
66
|
+
def order(direction)
|
67
|
+
"#{direction.target.field} #{direction.operator.to_s.upcase}"
|
68
|
+
end
|
69
|
+
|
70
|
+
def equality_operator(property, operand)
|
71
|
+
operand.nil? ? 'IS' : '='
|
72
|
+
end
|
73
|
+
|
74
|
+
def include_operator(property, operand)
|
75
|
+
case operand
|
76
|
+
when Array then 'IN'
|
77
|
+
when Range then 'BETWEEN'
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def like_operator(operand)
|
82
|
+
"LIKE"
|
83
|
+
end
|
84
|
+
|
85
|
+
def quote_value(value, property)
|
86
|
+
if property.type == Property::Boolean
|
87
|
+
# True on salesforce needs to be TRUE/FALSE for WHERE clauses but not for inserts.
|
88
|
+
return value == Property::Boolean::TRUE ? 'TRUE' : 'FALSE'
|
89
|
+
end
|
90
|
+
|
91
|
+
case value
|
92
|
+
when Array then "(#{value.map {|v| quote_value(v, property)}.join(", ")})"
|
93
|
+
when NilClass then "NULL"
|
94
|
+
when String then "'#{value.gsub(/'/, "\\'").gsub(/\\/, %{\\\\})}'"
|
95
|
+
else "#{value}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
metadata
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dm-salesforce-adapter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 23
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 1
|
8
|
+
- 0
|
9
|
+
- 0
|
10
|
+
version: 1.0.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Yehuda Katz
|
14
|
+
- Tim Carey-Smith
|
15
|
+
- Andy Delcambre
|
16
|
+
- Jordan Ritter
|
17
|
+
autorequire:
|
18
|
+
bindir: bin
|
19
|
+
cert_chain: []
|
20
|
+
|
21
|
+
date: 2010-12-23 00:00:00 -05:00
|
22
|
+
default_executable:
|
23
|
+
dependencies:
|
24
|
+
- !ruby/object:Gem::Dependency
|
25
|
+
name: httpclient
|
26
|
+
prerelease: false
|
27
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - "="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
hash: 119
|
33
|
+
segments:
|
34
|
+
- 2
|
35
|
+
- 1
|
36
|
+
- 5
|
37
|
+
- 2
|
38
|
+
version: 2.1.5.2
|
39
|
+
type: :runtime
|
40
|
+
version_requirements: *id001
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: extlib
|
43
|
+
prerelease: false
|
44
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
45
|
+
none: false
|
46
|
+
requirements:
|
47
|
+
- - ">"
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
hash: 37
|
50
|
+
segments:
|
51
|
+
- 0
|
52
|
+
- 9
|
53
|
+
- 15
|
54
|
+
version: 0.9.15
|
55
|
+
type: :runtime
|
56
|
+
version_requirements: *id002
|
57
|
+
- !ruby/object:Gem::Dependency
|
58
|
+
name: dm-core
|
59
|
+
prerelease: false
|
60
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ~>
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
hash: 19
|
66
|
+
segments:
|
67
|
+
- 1
|
68
|
+
- 0
|
69
|
+
- 2
|
70
|
+
version: 1.0.2
|
71
|
+
type: :runtime
|
72
|
+
version_requirements: *id003
|
73
|
+
- !ruby/object:Gem::Dependency
|
74
|
+
name: dm-validations
|
75
|
+
prerelease: false
|
76
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
77
|
+
none: false
|
78
|
+
requirements:
|
79
|
+
- - ~>
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
hash: 19
|
82
|
+
segments:
|
83
|
+
- 1
|
84
|
+
- 0
|
85
|
+
- 2
|
86
|
+
version: 1.0.2
|
87
|
+
type: :runtime
|
88
|
+
version_requirements: *id004
|
89
|
+
- !ruby/object:Gem::Dependency
|
90
|
+
name: dm-types
|
91
|
+
prerelease: false
|
92
|
+
requirement: &id005 !ruby/object:Gem::Requirement
|
93
|
+
none: false
|
94
|
+
requirements:
|
95
|
+
- - ~>
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
hash: 19
|
98
|
+
segments:
|
99
|
+
- 1
|
100
|
+
- 0
|
101
|
+
- 2
|
102
|
+
version: 1.0.2
|
103
|
+
type: :runtime
|
104
|
+
version_requirements: *id005
|
105
|
+
- !ruby/object:Gem::Dependency
|
106
|
+
name: soap4r
|
107
|
+
prerelease: false
|
108
|
+
requirement: &id006 !ruby/object:Gem::Requirement
|
109
|
+
none: false
|
110
|
+
requirements:
|
111
|
+
- - ~>
|
112
|
+
- !ruby/object:Gem::Version
|
113
|
+
hash: 19
|
114
|
+
segments:
|
115
|
+
- 1
|
116
|
+
- 5
|
117
|
+
- 8
|
118
|
+
version: 1.5.8
|
119
|
+
type: :runtime
|
120
|
+
version_requirements: *id006
|
121
|
+
description: A DataMapper 1.0x adapter to the Salesforce API
|
122
|
+
email: jpr5@cloudcrowd.com
|
123
|
+
executables: []
|
124
|
+
|
125
|
+
extensions: []
|
126
|
+
|
127
|
+
extra_rdoc_files:
|
128
|
+
- README.markdown
|
129
|
+
- LICENSE
|
130
|
+
files:
|
131
|
+
- LICENSE
|
132
|
+
- README.markdown
|
133
|
+
- Rakefile
|
134
|
+
- lib/dm-salesforce-adapter/adapter.rb
|
135
|
+
- lib/dm-salesforce-adapter/connection/errors.rb
|
136
|
+
- lib/dm-salesforce-adapter/connection.rb
|
137
|
+
- lib/dm-salesforce-adapter/property/boolean.rb
|
138
|
+
- lib/dm-salesforce-adapter/property/serial.rb
|
139
|
+
- lib/dm-salesforce-adapter/property.rb
|
140
|
+
- lib/dm-salesforce-adapter/resource.rb
|
141
|
+
- lib/dm-salesforce-adapter/soap_wrapper.rb
|
142
|
+
- lib/dm-salesforce-adapter/sql.rb
|
143
|
+
- lib/dm-salesforce-adapter/version.rb
|
144
|
+
- lib/dm-salesforce-adapter.rb
|
145
|
+
has_rdoc: true
|
146
|
+
homepage: http://github.com/cloudcrowd/dm-salesforce-adapter
|
147
|
+
licenses: []
|
148
|
+
|
149
|
+
post_install_message:
|
150
|
+
rdoc_options: []
|
151
|
+
|
152
|
+
require_paths:
|
153
|
+
- lib
|
154
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
155
|
+
none: false
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
hash: 3
|
160
|
+
segments:
|
161
|
+
- 0
|
162
|
+
version: "0"
|
163
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
164
|
+
none: false
|
165
|
+
requirements:
|
166
|
+
- - ">="
|
167
|
+
- !ruby/object:Gem::Version
|
168
|
+
hash: 3
|
169
|
+
segments:
|
170
|
+
- 0
|
171
|
+
version: "0"
|
172
|
+
requirements: []
|
173
|
+
|
174
|
+
rubyforge_project:
|
175
|
+
rubygems_version: 1.3.7
|
176
|
+
signing_key:
|
177
|
+
specification_version: 3
|
178
|
+
summary: A DataMapper 1.0x adapter to the Salesforce API
|
179
|
+
test_files: []
|
180
|
+
|