rfm 0.2.0 → 1.0.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.
data/lib/rfm.rb CHANGED
@@ -1,5 +1,232 @@
1
+ # RFM provides easy access to FileMaker Pro data. With it, Ruby scripts can
2
+ # perform finds, read records and fields, update data, and perform scripts using
3
+ # a simple ruby-like syntax.
4
+ #
5
+ # Author:: Geoff Coffey (mailto:gwcoffey@gmail.com)
6
+ # Copyright:: Copyright (c) 2007 Six Fried Rice, LLC and Mufaddal Khumri
7
+ # License:: See MIT-LICENSE for details
8
+ #
9
+ # RFM uses the FileMaker XML API, so it requires:
10
+ # - FileMaker Server 9.0 or later
11
+ # - or FileMaker Server Advanced 7.0 or later
12
+ #
13
+ # This documentation serves as a reference to the classes in the API. For more complete
14
+ # usage documentation, see the RFM home page at http://sixfriedrice.com/wp/products/rfm/
15
+ #
16
+ # = Quick Start
17
+ #
18
+ # Rfm is a Gem. As such, any ruby file that uses it, needs to have these two lines on top:
19
+ #
20
+ # require "rubygems"
21
+ # require "rfm"
22
+ #
23
+ # (If you don't have Rfm installed, use the +gem install rfm+ command to get it.)
24
+ #
25
+ # === Get a Server
26
+ #
27
+ # Everything in Rfm starts with the Server object. You create a Server object like this:
28
+ #
29
+ # myServer = Rfm::Server.new(
30
+ # :host => "yourhost",
31
+ # :account_name => "someone",
32
+ # :pasword => "secret"
33
+ # )
34
+ #
35
+ # The Server object supports many other options, which you'll find explained in its
36
+ # documentation.
37
+ #
38
+ # Note: The account name and password are optional. You can instead provide them on
39
+ # a per-database basis (using Database::account_name and Database::password). But
40
+ # it is convenient to do it here because you often have one set of credentials
41
+ # across all databases. Also, you must provide an account_name and password if you
42
+ # want to ask the server for a list of available databases.
43
+ #
44
+ # === Get a Database
45
+ #
46
+ # Once you have a Server object, you can use it to get a Database. For example, if your
47
+ # database is called "Customers", you get it like this:
48
+ #
49
+ # myDatabase = myServer["Customers"]
50
+ #
51
+ # If you need to supply account and password info specifically for this database
52
+ # (rather than doing it at the Server level), do this:
53
+ #
54
+ # myDatabase.account_name = "someone"
55
+ # myDatabase.password = "secret"
56
+ #
57
+ # *IMPORTANT NOTE:* The account name you use to access FileMaker must have the
58
+ # +fmxml+ extended privilege. In other words, edit its privilege set and turn on
59
+ # "Access via XML Web Publishing (fmxml)" in the Extended Privileges section
60
+ # at the bottom-left of the Edit Privilege Set window. If you don't do this,
61
+ # Rfm will report that it can't log in.
62
+ #
63
+ # === Get a Layout
64
+ #
65
+ # Every action you send to FileMaker always goes through a layout. This is how Rfm knows
66
+ # which table you want to work with, and which fields on that table you care about. This
67
+ # should feel pretty familiar now:
68
+ #
69
+ # myLayout = myDatabase["Details"]
70
+ #
71
+ # You might use layouts you already have, or make new layout just for Rfm. Just remember that
72
+ # if you delete a layout, or remove a field from a layout that your Rfm code uses, the
73
+ # code will stop working.
74
+ #
75
+ # === Putting it Together
76
+ #
77
+ # Usually you don't care much about the intermediate Database object (it's a gateway object,
78
+ # if you will). So it is often easiest to combine all the above steps like this:
79
+ #
80
+ # myLayout = myServer["Customers"]["Details"]
81
+ #
82
+ # === Performing Actions
83
+ #
84
+ # The Layout object can do a lot of things (see its documentation for a full list). But
85
+ # in general, it involves records. For instance, you can find records:
86
+ #
87
+ # result = myLayout.find({"First Name" => "Bill"})
88
+ #
89
+ # That code finds everybody whose first name in Bill. All the Layout methods return an
90
+ # ResultSet object. It contains the records, as well as metadata about the fields and
91
+ # portals on the layout. Usually you'll only concern yourself with the records (and you
92
+ # can read about the others in the ResultSet documentation).
93
+ #
94
+ # ResultSet is a subclass of Array, Ruby's built in array type. So you can treate it just
95
+ # like any other array:
96
+ #
97
+ # first_record = result[0]
98
+ # a_few_records = result[3,7]
99
+ # record_count = result.size
100
+ #
101
+ # But usually you'll want to loop through them all. Because this is an array, you can use
102
+ # code that is familiar to any Ruby whiz:
103
+ #
104
+ # result.each { |record|
105
+ # # do something with record here
106
+ # }
107
+ #
108
+ # === Working with Records
109
+ #
110
+ # The records in a ResultSet are actually Record objects. They hold the actual data from
111
+ # FileMaker. Record subclasses Hash, another built in Ruby type, so you can use them like
112
+ # this:
113
+ #
114
+ # full_name = record["First Name"] + ' ' + record["Last Name"]
115
+ # info.merge(record)
116
+ # record.each_value { |value| puts value }
117
+ # if record.value?("Bill") then puts "Bill is in there somewhere"
118
+ #
119
+ # The field name serves as the hash key, so these examples get fields called First Name and
120
+ # Last Name. (Note: Unlike a typical Ruby hash, Record objects are not case sensitive. You
121
+ # can say +record["first name"]+ or +record["FIRST NAME"]+ and it will still work.)
122
+ #
123
+ # A record object has the power to save changes to itself back to the database. For example:
124
+ #
125
+ # records.each { |record|
126
+ # record["First Name"] = record["First Name"].upcase
127
+ # record.save
128
+ # }
129
+ #
130
+ # That concise code converts the First Name field to all uppercase in every record in the
131
+ # ResultSet. Note that each time you call Record::save, if the record has been modified,
132
+ # Rfm has to send an action to FileMaker. A loop like the one above will be quite slow
133
+ # across many records. There is not fast way to update lots of records at once right now,
134
+ # although you might be able to accomplish it with a FileMaker script by passing a
135
+ # parameter).
136
+ #
137
+ # === Editing and Deleting Records
138
+ #
139
+ # Any time you edit or delete a record, you *must* provide the record's internal record
140
+ # if. This is not the value in any field. Rather, it is the ID FileMaker assigns to the
141
+ # record internally. So an edit or delete is almost always a two-step process:
142
+ #
143
+ # record = myLayout.find({"Customer ID" => "1234"})[0]
144
+ # myLayout.edit(record.record_id, {"First Name" => "Steve"})
145
+ #
146
+ # The code above first finds a Customer record. It then uses the Record::record_id method
147
+ # to discover that record's internal id. That id is passed to the Layout::edit method.
148
+ # The edit method also accepts a hash of record changes. In this case, we're changing
149
+ # the value in the First Name field to "Steve".
150
+ #
151
+ # Also, note the [0] on the end of the first line. A find _always_ returns a ResultSet.
152
+ # If there's only one record, it is still in an array. This array just happens to have only
153
+ # one element. The [0] pulls out that single record.
154
+ #
155
+ # To delete a record, you would do this instead:
156
+ #
157
+ # record = myLayout.find({"Customer ID" => "1234"})[0]
158
+ # myLayout.delete(record.record_id)
159
+ #
160
+ # Finally, the Layout::find method can also find a record using its internal id:
161
+ #
162
+ # record = myLayout.find(some_id)
163
+ #
164
+ # If the parameter you pass to Layout::find is not a hash, it is converted to a string
165
+ # and assumed to be a record id.
166
+ #
167
+ # === Performing Scripts
168
+ #
169
+ # Rfm can run a script in conjunction with any other action. For example, you might want
170
+ # to find a set of records, then run a script on them all. Or you may want to run a script
171
+ # when you delete a record. Here's how:
172
+ #
173
+ # myLayout.find({"First Name" => "Bill"}, {:post_script => "Process Sales"})
174
+ #
175
+ # This code finds every record with "Bill" in the First Name field, then runs the script
176
+ # called "Process Sales." You can control when the script actually runs, as explained in
177
+ # the documentation for Common Options for the Layout class.
178
+ #
179
+ # You can also pass a parameter to the script when it runs. Here's the deal:
180
+ #
181
+ # myLayout.find(
182
+ # {"First Name" => "Bill"},
183
+ # {:post_script => ["Process Sales", "all"]}
184
+ # )
185
+ #
186
+ # This time, the text value "all" is passed to the script as a script parameter.
187
+ #
188
+ # =Notes on Rfm with Ruby on Rails
189
+ #
190
+ # Rfm is a great fit for Rails. But it isn't ActiveRecord, so you need to do things
191
+ # a little differently.
192
+ #
193
+ # === Configuration
194
+ #
195
+ # To avoid having to reconfigure your Server object in every Rails action, you
196
+ # might add a configuration hash to the environment.rb. It can include all the
197
+ # options you need to connecto to your server:
198
+ #
199
+ # RFM_CONFIG = {
200
+ # :host => "yourhost",
201
+ # :account_name => "someone",
202
+ # :password => "secret",
203
+ # :db => "Customers"
204
+ # }
205
+ #
206
+ # Then you can get a server concisely:
207
+ #
208
+ # myServer = Server.net(RFM_CONFIG)
209
+ # myServer[RFM_CONFIG[:db]]["My Layout"]...
210
+ #
211
+ # You might even want to add code to your application.rb to centralize access
212
+ # to your various layouts.
213
+ #
214
+ # === Disable ActiveRecord
215
+ #
216
+ # If you're not using any SQL database in your Rails app, you'll quickly discover
217
+ # that Rails insists on a SQL database configuration anyway. This is easy to fix.
218
+ # Just turn off ActiveRecord. In the environment.rb, find the line that starts with
219
+ # +config.frameworks+. This is where you can disable the parts of Rails you're not
220
+ # using. Uncomment the line and make it look like this:
221
+ #
222
+ # config.frameworks -= [ :active_record ]
223
+ #
224
+ # Now Rails will no longer insist on a SQL database.
225
+
1
226
  $: << File.expand_path(File.dirname(__FILE__))
2
227
 
3
228
  require 'rfm_command'
229
+ require 'rfm_util'
4
230
  require 'rfm_result'
5
- require 'rfm_factory'
231
+ require 'rfm_factory'
232
+ require 'rfm_error'
@@ -2,62 +2,223 @@ require 'net/http'
2
2
  require 'rexml/document'
3
3
  require 'cgi'
4
4
 
5
+ # This module includes classes that represent base FileMaker concepts like servers,
6
+ # layouts, and scripts. These classes allow you to communicate with FileMaker Server,
7
+ # send commands, and receive responses.
8
+ #
9
+ # Author:: Geoff Coffey (mailto:gwcoffey@gmail.com)
10
+ # Copyright:: Copyright (c) 2007 Six Fried Rice, LLC and Mufaddal Khumri
11
+ # License:: See MIT-LICENSE for details
5
12
  module Rfm
6
13
 
14
+ # This class represents a single FileMaker server. It is initialized with basic
15
+ # connection information, including the hostname, port number, and default database
16
+ # account name and password.
17
+ #
18
+ # Note: The host and port number refer to the FileMaker Web Publishing Engine, which
19
+ # must be installed and configured in order to use RFM. It may not actually be running
20
+ # on the same server computer as FileMaker Server itself. See your FileMaker Server
21
+ # or FileMaker Server Advanced documentation for information about configuring a Web
22
+ # Publishing Engine.
23
+ #
24
+ # =Accessing Databases
25
+ #
26
+ # Typically, you access a Database object from the Server like this:
27
+ #
28
+ # myDatabase = myServer["Customers"]
29
+ #
30
+ # This code gets the Database object representing the Customers object.
31
+ #
32
+ # Note: RFM does not talk to the server when you retrieve a database object in this way. Instead, it
33
+ # simply assumes you know what you're talking about. If the database you specify does not exist, you
34
+ # will get no error at this point. Instead, you'll get an error when you use the Layout object you get
35
+ # from this database. This makes debugging a little less convenient, but it would introduce too much
36
+ # overhead to hit the server at this point.
37
+ #
38
+ # The Server object has a +db+ attribute that provides alternate access to Database objects. It acts
39
+ # like a hash of Database objects, one for each accessible database on the server. So, for example, you
40
+ # can do this if you want to print out a list of all databses on the server:
41
+ #
42
+ # myServer.db.each {|database|
43
+ # puts database.name
44
+ # }
45
+ #
46
+ # The Server::db attribute is actually a DbFactory object, although it subclasses hash, so it should work
47
+ # in all the ways you expect. Note, though, that it is completely empty until the first time you attempt
48
+ # to access its elements. At that (lazy) point, it hits FileMaker, loads in the list of databases, and
49
+ # constructs a Database object for each one. In other words, it incurrs no overhead until you use it.
50
+ #
51
+ # =Attributes
52
+ #
53
+ # In addition to the +db+ attribute, Server has a few other useful attributes:
54
+ #
55
+ # * *host_name* is the host name this server points to
56
+ # * *post* is the port number this server communicates on
57
+ # * *state* is a hash of all server options used to initialize this server
58
+
7
59
  class Server
8
60
 
61
+ # To create a Server obejct, you typically need at least a host name:
62
+ #
63
+ # myServer = Rfm::Server.new({:host => 'my.host.com'})
64
+ #
65
+ # Several other options are supported:
66
+ #
67
+ # * *host* the hostname of the Web Publishing Engine (WPE) server (defaults to 'localhost')
68
+ #
69
+ # * *port* the port number the WPE is listening no (defaults to 80)
70
+ #
71
+ # * *ssl* +true+ if you want to use SSL (HTTPS) to connect to FileMaker (defaults to +false+)
72
+ #
73
+ # * *account_name* the default account name to log in to databases with (you can also supply a
74
+ # account name on a per-database basis if necessary)
75
+ #
76
+ # * *password* the default password to log in to databases with (you can also supplly a password
77
+ # on a per-databases basis if necessary)
78
+ #
79
+ # * *log_actions* when +true+, RFM logs all action URLs that are sent to FileMaker server to stderr
80
+ # (defaults to +false+)
81
+ #
82
+ # * *log_responses* when +true+, RFM logs all raw XML responses (including headers) from FileMaker to
83
+ # stderr (defaults to +false+)
84
+ #
85
+ # * *warn_on_redirect* normally, RFM prints a warning to stderr if the Web Publishing Engine redirects
86
+ # (this can usually be fixed by using a different host name, which speeds things up); if you *don't*
87
+ # want this warning printed, set +warn_on_redirect+ to +true+
88
+ #
89
+ # * *raise_on_401* although RFM raises error when FileMaker returns error responses, it typically
90
+ # ignores FileMaker's 401 error (no records found) and returns an empty record set instead; if you
91
+ # prefer a raised error when a find produces no errors, set this option to +true+
9
92
  def initialize(options)
10
93
  @state = {
11
94
  :host => 'localhost',
12
95
  :port => 80,
13
- :username => '',
96
+ :ssl => false,
97
+ :account_name => '',
14
98
  :password => '',
15
99
  :log_actions => false,
16
100
  :log_responses => false,
101
+ :warn_on_redirect => true,
17
102
  :raise_on_401 => false
18
- }.merge(options).freeze
103
+ }.merge(options)
104
+
105
+ if @state[:username] != nil
106
+ warn("the :username option on Rfm::Server::initialize has been deprecated. Use :account_name instead.")
107
+ @state[:account_name] = @state[:username]
108
+ end
109
+
110
+ @state.freeze
19
111
 
20
112
  @host_name = @state[:host]
113
+ @scheme = @state[:ssl] ? "https" : "http"
21
114
  @port = @state[:port]
22
- @username = @state[:username]
23
- @password = @state[:password]
24
115
 
25
116
  @db = Rfm::Factory::DbFactory.new(self)
26
117
  end
27
-
118
+
119
+ # Access the database object representing a database on the server. For example:
120
+ #
121
+ # myServer['Customers']
122
+ #
123
+ # would return a Database object representing the _Customers_
124
+ # database on the server.
125
+ #
126
+ # Note: RFM never talks to the server until you perform an action. The database object
127
+ # returned is created on the fly and assumed to refer to a valid database, but you will
128
+ # get no error at this point if the database you access doesn't exist. Instead, you'll
129
+ # receive an error when you actually try to perform some action on a layout from this
130
+ # database.
28
131
  def [](dbname)
29
132
  self.db[dbname]
30
133
  end
31
134
 
32
- attr_reader :db, :host_name, :port, :state
135
+ attr_reader :db, :host_name, :port, :scheme, :state
33
136
 
34
- def do_action(action, args, options = {})
137
+ # Performs a raw FileMaker action. You will generally not call this method directly, but it
138
+ # is exposed in case you need to do something "under the hood."
139
+ #
140
+ # The +action+ parameter is any valid FileMaker web url action. For example, +-find+, +-finadny+ etc.
141
+ #
142
+ # The +args+ parameter is a hash of arguments to be included in the action url. It will be serialized
143
+ # and url-encoded appropriately.
144
+ #
145
+ # The +options+ parameter is a hash of RFM-specific options, which correspond to the more esoteric
146
+ # FileMaker URL parameters. They are exposed separately because they can also be passed into
147
+ # various methods on the Layout object, which is a much more typical way of sending an action to
148
+ # FileMaker.
149
+ #
150
+ # This method returns the Net::HTTP response object representing the response from FileMaker.
151
+ #
152
+ # For example, if you wanted to send a raw command to FileMaker to find the first 20 people in the
153
+ # "Customers" database whose first name is "Bill" you might do this:
154
+ #
155
+ # response = myServer.do_action(
156
+ # '-find',
157
+ # {
158
+ # "-db" => "Customers",
159
+ # "-lay" => "Details",
160
+ # "First Name" => "Bill"
161
+ # },
162
+ # { :max_records => 20 }
163
+ # )
164
+ def do_action(account_name, password, action, args, options = {})
35
165
  post = args.merge(expand_options(options)).merge({action => ''})
36
-
166
+ http_fetch(@host_name, @port, "/fmi/xml/fmresultset.xml", account_name, password, post)
167
+ end
168
+
169
+ def load_layout(layout)
170
+ post = {'-db' => layout.db.name, '-lay' => layout.name, '-view' => ''}
171
+ http_fetch(@host_name, @port, "/fmi/xml/FMPXMLLAYOUT.xml", layout.db.account_name, layout.db.password, post)
172
+ end
173
+
174
+ private
175
+
176
+ def http_fetch(host_name, port, path, account_name, password, post_data, limit = 10)
177
+ if limit == 0
178
+ raise Rfm::Error::CommunicationError.new("While trying to reach the Web Publishing Engine, RFM was redirected too many times.")
179
+ end
180
+
37
181
  if @state[:log_actions] == true
38
- qs = post.collect{|key,val| "#{CGI::escape(key.to_s)}=#{CGI::escape(val.to_s)}"}.join("&")
39
- warn "http://#{@host_name}:#{@port}/fmi/xml/fmresultset.xml?#{qs}"
182
+ qs = post_data.collect{|key,val| "#{CGI::escape(key.to_s)}=#{CGI::escape(val.to_s)}"}.join("&")
183
+ warn "#{@scheme}://#{@host_name}:#{@port}#{path}?#{qs}"
40
184
  end
41
-
42
- request = Net::HTTP::Post.new("/fmi/xml/fmresultset.xml")
43
- request.basic_auth(@username, @password)
44
- request.set_form_data(post)
45
- result = Net::HTTP.start(@host_name, @port) { |http|
185
+
186
+ request = Net::HTTP::Post.new(path)
187
+ request.basic_auth(account_name, password)
188
+ request.set_form_data(post_data)
189
+
190
+ response = Net::HTTP.start(host_name, port) { |http|
46
191
  http.request(request)
47
192
  }
48
193
 
49
194
  if @state[:log_responses] == true
50
- result.to_hash.each {|key, value|
195
+ response.to_hash.each {|key, value|
51
196
  warn "#{key}: #{value}"
52
197
  }
53
- warn result.body
198
+ warn response.body
199
+ end
200
+
201
+ case response
202
+ when Net::HTTPSuccess
203
+ response
204
+ when Net::HTTPRedirection
205
+ if @state[:warn_on_redirect]
206
+ warn "The web server redirected to " + response['location'] + ". You should revise your connection hostname or fix your server configuration if possible to improve performance."
207
+ end
208
+ newloc = URI.parse(response['location'])
209
+ http_fetch(newloc.host, newloc.port, newloc.request_uri, account_name, password, post_data, limit - 1)
210
+ when Net::HTTPUnauthorized
211
+ msg = "The account name (#{account_name}) or password provided is not correct (or the account doesn't have the fmxml extended privilege)."
212
+ raise Rfm::Error::AuthenticationError.new(msg)
213
+ when Net::HTTPNotFound
214
+ msg = "Could not talk to FileMaker because the Web Publishing Engine is not responding (server returned 404)."
215
+ raise Rfm::Error::CommunicationError.new(msg)
216
+ else
217
+ msg = "Unexpected response from server: #{result.code} (#{result.class.to_s}). Unable to communicate with the Web Publishing Engine."
218
+ raise Rfm::Error::CommunicationError.new(msg)
54
219
  end
55
-
56
- result
57
220
  end
58
-
59
- private
60
-
221
+
61
222
  def expand_options(options)
62
223
  result = {}
63
224
  options.each {|key,value|
@@ -67,7 +228,16 @@ module Rfm
67
228
  when :skip_records:
68
229
  result['-skip'] = value
69
230
  when :sort_field:
70
- result['-sortfield'] = value
231
+ if value.kind_of? Array
232
+ if value.size > 9
233
+ raise Rfm::Error::ParameterError.new(":sort_field can have at most 9 fields, but you passed an array with #{value.size} elements.")
234
+ end
235
+ value.each_index {|i|
236
+ result["-sortfield.#{i+1}"] = value[i]
237
+ }
238
+ else
239
+ result["-sortfield.1"] = value
240
+ end
71
241
  when :sort_order:
72
242
  result['-sortorder'] = value
73
243
  when :post_script:
@@ -98,7 +268,7 @@ module Rfm
98
268
  when :modification_id:
99
269
  result['-modid'] = value
100
270
  else
101
- raise "Invalid option: #{key} (are you using a string instead of a symbol?)"
271
+ raise Rfm::Error::ParameterError.new("Invalid option: #{key} (are you using a string instead of a symbol?)")
102
272
  end
103
273
  }
104
274
  result
@@ -106,50 +276,318 @@ module Rfm
106
276
 
107
277
  end
108
278
 
279
+ # The Database object represents a single FileMaker Pro database. When you retrieve a Database
280
+ # object from a server, its account name and password are set to the account name and password you
281
+ # used when initializing the Server object. You can override this of course:
282
+ #
283
+ # myDatabase = myServer["Customers"]
284
+ # myDatabase.account_name = "foo"
285
+ # myDatabase.password = "bar"
286
+ #
287
+ # =Accessing Layouts
288
+ #
289
+ # All interaction with FileMaker happens through a Layout object. You can get a Layout object
290
+ # from the Database object like this:
291
+ #
292
+ # myLayout = myDatabase["Details"]
293
+ #
294
+ # This code gets the Layout object representing the layout called Details in the database.
295
+ #
296
+ # Note: RFM does not talk to the server when you retrieve a Layout object in this way. Instead, it
297
+ # simply assumes you know what you're talking about. If the layout you specify does not exist, you
298
+ # will get no error at this point. Instead, you'll get an error when you use the Layout object methods
299
+ # to talk to FileMaker. This makes debugging a little less convenient, but it would introduce too much
300
+ # overhead to hit the server at this point.
301
+ #
302
+ # The Database object has a +layout+ attribute that provides alternate access to Layout objects. It acts
303
+ # like a hash of Layout objects, one for each accessible layout in the database. So, for example, you
304
+ # can do this if you want to print out a list of all layouts:
305
+ #
306
+ # myDatabase.layout.each {|layout|
307
+ # puts layout.name
308
+ # }
309
+ #
310
+ # The Database::layout attribute is actually a LayoutFactory object, although it subclasses hash, so it
311
+ # should work in all the ways you expect. Note, though, that it is completely empty until the first time
312
+ # you attempt to access its elements. At that (lazy) point, it hits FileMaker, loads in the list of layouts,
313
+ # and constructs a Layout object for each one. In other words, it incurrs no overhead until you use it.
314
+ #
315
+ # =Accessing Scripts
316
+ #
317
+ # If for some reason you need to enumerate the scripts in a database, you can do so:
318
+ #
319
+ # myDatabase.script.each {|script|
320
+ # puts script.name
321
+ # }
322
+ #
323
+ # The Database::script attribute is actually a ScriptFactory object, although it subclasses hash, so it
324
+ # should work in all the ways you expect. Note, though, that it is completely empty until the first time
325
+ # you attempt to access its elements. At that (lazy) point, it hits FileMaker, loads in the list of scripts,
326
+ # and constructs a Script object for each one. In other words, it incurrs no overhead until you use it.
327
+ #
328
+ # Note: You don't need a Script object to _run_ a script (see the Layout object instead).
329
+ #
330
+ # =Attributes
331
+ #
332
+ # In addition to the +layout+ attribute, Server has a few other useful attributes:
333
+ #
334
+ # * *server* is the Server object this database comes from
335
+ # * *name* is the name of this database
336
+ # * *state* is a hash of all server options used to initialize this server
109
337
  class Database
338
+
339
+ # Initialize a database object. You never really need to do this. Instead, just do this:
340
+ #
341
+ # myServer = Rfm::Server.new(...)
342
+ # myDatabase = myServer["Customers"]
343
+ #
344
+ # This sample code gets a database object representing the Customers database on the FileMaker server.
110
345
  def initialize(name, server)
111
346
  @name = name
112
347
  @server = server
348
+ @account_name = server.state[:account_name] or ""
349
+ @password = server.state[:password] or ""
113
350
  @layout = Rfm::Factory::LayoutFactory.new(server, self)
114
351
  @script = Rfm::Factory::ScriptFactory.new(server, self)
115
352
  end
116
353
 
117
- attr_reader :server, :name, :layout, :script
354
+ attr_reader :server, :name, :account_name, :password, :layout, :script
355
+ attr_writer :account_name, :password
118
356
 
357
+ # Access the Layout object representing a layout in this database. For example:
358
+ #
359
+ # myDatabase['Details']
360
+ #
361
+ # would return a Layout object representing the _Details_
362
+ # layout in the database.
363
+ #
364
+ # Note: RFM never talks to the server until you perform an action. The Layout object
365
+ # returned is created on the fly and assumed to refer to a valid layout, but you will
366
+ # get no error at this point if the layout you specify doesn't exist. Instead, you'll
367
+ # receive an error when you actually try to perform some action it.
119
368
  def [](layout_name)
120
369
  self.layout[layout_name]
121
370
  end
122
371
 
123
372
  end
124
373
 
374
+ # The Layout object represents a single FileMaker Pro layout. You use it to interact with
375
+ # records in FileMaker. *All* access to FileMaker data is done through a layout, and this
376
+ # layout determins which _table_ you actually hit (since every layout is explicitly associated
377
+ # with a particular table -- see FileMakers Layout->Layout Setup dialog box). You never specify
378
+ # _table_ information directly in RFM.
379
+ #
380
+ # Also, the layout determines which _fields_ will be returned. If a layout contains only three
381
+ # fields from a large table, only those three fields are returned. If a layout includes related
382
+ # fields from another table, they are returned as well. And if the layout includes portals, all
383
+ # data in the portals is returned (see Record::portal for details).
384
+ #
385
+ # As such, you can _significantly_ improve performance by limiting what you put on the layout.
386
+ #
387
+ # =Using Layouts
388
+ #
389
+ # The Layout object is where you get most of your work done. It includes methods for all
390
+ # FileMaker actions:
391
+ #
392
+ # * Layout::all
393
+ # * Layout::any
394
+ # * Layout::find
395
+ # * Layout::edit
396
+ # * Layout::create
397
+ # * Layout::delete
398
+ #
399
+ # =Running Scripts
400
+ #
401
+ # In FileMaker, execution of a script must accompany another action. For example, to run a script
402
+ # called _Remove Duplicates_ with a found set that includes everybody
403
+ # named _Bill_, do this:
404
+ #
405
+ # myLayout.find({"First Name" => "Bill"}, :post_script => "Remove Duplicates")
406
+ #
407
+ # ==Controlling When the Script Runs
408
+ #
409
+ # When you perform an action in FileMaker, it always executes in this order:
410
+ #
411
+ # 1. Perform any find
412
+ # 2. Sort the records
413
+ # 3. Return the results
414
+ #
415
+ # You can control when in the process the script runs. Each of these options is available:
416
+ #
417
+ # * *post_script* tells FileMaker to run the script after finding and sorting
418
+ # * *pre_find_script* tells FileMaker to run the script _before_ finding
419
+ # * *pre_sort_script* tells FileMaker to run the script _before_ sorting, but _after_ finding
420
+ #
421
+ # ==Passing Parameters to a Script
422
+ #
423
+ # If you want to pass a parameter to the script, use the options above, but supply an array value
424
+ # instead of a single string. For example:
425
+ #
426
+ # myLayout.find({"First Name" => "Bill"}, :post_script => ["Remove Duplicates", 10])
427
+ #
428
+ # This sample runs the script called "Remove Duplicates" and passes it the value +10+ as its
429
+ # script parameter.
430
+ #
431
+ # =Common Options
432
+ #
433
+ # Most of the methods on the Layout object accept an optional hash of +options+ to manipulate the
434
+ # action. For example, when you perform a find, you will typiclaly get back _all_ matching records.
435
+ # If you want to limit the number of records returned, you can do this:
436
+ #
437
+ # myLayout.find({"First Name" => "Bill"}, :max_records => 100)
438
+ #
439
+ # The +:max_records+ option tells FileMaker to limit the number of records returned.
440
+ #
441
+ # This is the complete list of available options:
442
+ #
443
+ # * *max_records* tells FileMaker how many records to return
444
+ #
445
+ # * *skip_records* tells FileMaker how many records in the found set to skip, before
446
+ # returning results; this is typically combined with +max_records+ to "page" through
447
+ # records
448
+ #
449
+ # * *sort_field* tells FileMaker to sort the records by the specified field
450
+ #
451
+ # * *sort_order* can be +desc+ (descending) or +asc+ (ascending) and determines the order
452
+ # of the sort when +sort_field+ is specified
453
+ #
454
+ # * *post_script* tells FileMaker to perform a script after carrying out the action; you
455
+ # can pass the script name, or a two-element array, with the script name first, then the
456
+ # script parameter
457
+ #
458
+ # * *pre_find_script* is like +post_script+ except the script runs before any find is
459
+ # performed
460
+ #
461
+ # * *pre_sort_script* is like +pre_find_script+ except the script runs after any find
462
+ # and before any sort
463
+ #
464
+ # * *response_layout* tells FileMaker to switch layouts before producing the response; this
465
+ # is useful when you need a field on a layout to perform a find, edit, or create, but you
466
+ # want to improve performance by not including the field in the result
467
+ #
468
+ # * *logical_operator* can be +and+ or +or+ and tells FileMaker how to process multiple fields
469
+ # in a find request
470
+ #
471
+ # * *modification_id* lets you pass in the modification id from a Record object with the request;
472
+ # when you do, the action will fail if the record was modified in FileMaker after it was retrieved
473
+ # by RFM but before the action was run
474
+ #
475
+ #
476
+ # =Attributes
477
+ #
478
+ # The Layout object has a few useful attributes:
479
+ #
480
+ # * +name+ is the name of the layout
481
+ #
482
+ # * +field_controls+ is a hash of FieldControl objects, with the field names as keys. FieldControl's
483
+ # tell you about the field on the layout: how is it formatted and what value list is assigned
484
+ #
485
+ # Note: It is possible to put the same field on a layout more than once. When this is the case, the
486
+ # value in +field_controls+ for that field is an array with one element representing each instance
487
+ # of the field.
488
+ #
489
+ # * +value_lists+ is a hash of arrays. The keys are value list names, and the values in the hash
490
+ # are arrays containing the actual value list items. +value_lists+ will include every value
491
+ # list that is attached to any field on the layout
492
+
125
493
  class Layout
494
+
495
+ # Initialize a layout object. You never really need to do this. Instead, just do this:
496
+ #
497
+ # myServer = Rfm::Server.new(...)
498
+ # myDatabase = myServer["Customers"]
499
+ # myLayout = myDatabase["Details"]
500
+ #
501
+ # This sample code gets a layout object representing the Details layout in the Customers database
502
+ # on the FileMaker server.
503
+ #
504
+ # In case it isn't obvious, this is more easily expressed this way:
505
+ #
506
+ # myServer = Rfm::Server.new(...)
507
+ # myLayout = myServer["Customers"]["Details"]
126
508
  def initialize(name, db)
127
509
  @name = name
128
510
  @db = db
511
+
512
+ @loaded = false
513
+ @field_controls = Rfm::Util::CaseInsensitiveHash.new
514
+ @value_lists = Rfm::Util::CaseInsensitiveHash.new
129
515
  end
130
516
 
131
- attr_reader :name
517
+ attr_reader :name, :db
518
+
519
+ def field_controls
520
+ load if !@loaded
521
+ @field_controls
522
+ end
132
523
 
524
+ def value_lists
525
+ load if !@loaded
526
+ @value_lists
527
+ end
528
+
529
+ # Returns a ResultSet object containing _every record_ in the table associated with this layout.
133
530
  def all(options = {})
134
531
  get_records('-findall', {}, options)
135
532
  end
136
533
 
534
+ # Returns a ResultSet containing a single random record from the table associated with this layout.
137
535
  def any(options = {})
138
536
  get_records('-findany', {}, options)
139
537
  end
140
538
 
141
- def find(query_map, options = {})
142
- get_records('-find', query_map, options)
539
+ # Finds a record. Typically you will pass in a hash of field names and values. For example:
540
+ #
541
+ # myLayout.find({"First Name" => "Bill"})
542
+ #
543
+ # Values in the hash work just like value in FileMaker's Find mode. You can use any special
544
+ # symbols (+==+, +...+, +>+, etc...).
545
+ #
546
+ # If you pass anything other than a hash as the first parameter, it is converted to a string and
547
+ # assumed to be FileMaker's internal id for a record (the recid).
548
+ def find(hash_or_recid, options = {})
549
+ if hash_or_recid.kind_of? Hash
550
+ get_records('-find', hash_or_recid, options)
551
+ else
552
+ get_records('-find', {'-recid' => hash_or_recid.to_s}, options)
553
+ end
143
554
  end
144
555
 
556
+ # Updates the contents of the record whose internal +recid+ is specified. Send in a hash of new
557
+ # data in the +values+ parameter. Returns a RecordSet containing the modified record. For example:
558
+ #
559
+ # recid = myLayout.find({"First Name" => "Bill"})[0].record_id
560
+ # myLayout.edit(recid, {"First Name" => "Steve"})
561
+ #
562
+ # The above code would find the first record with _Bill_ in the First Name field and change the
563
+ # first name to _Steve_.
145
564
  def edit(recid, values, options = {})
146
565
  get_records('-edit', {'-recid' => recid}.merge(values), options)
147
566
  end
148
567
 
568
+ # Creates a new record in the table associated with this layout. Pass field data as a hash in the
569
+ # +values+ parameter. Returns the newly created record in a RecordSet. You can use the returned
570
+ # record to, ie, discover the values in auto-enter fields (like serial numbers).
571
+ #
572
+ # For example:
573
+ #
574
+ # result = myLayout.create({"First Name" => "Jerry", "Last Name" => "Robin"})
575
+ # id = result[0]["ID"]
576
+ #
577
+ # The above code adds a new record with first name _Jerry_ and last name _Robin_. It then
578
+ # puts the value from the ID field (a serial number) into a ruby variable called +id+.
149
579
  def create(values, options = {})
150
580
  get_records('-new', values, options)
151
581
  end
152
582
 
583
+ # Deletes the record with the specified internal recid. Returns a ResultSet with the deleted record.
584
+ #
585
+ # For example:
586
+ #
587
+ # recid = myLayout.find({"First Name" => "Bill"})[0].record_id
588
+ # myLayout.delete(recid)
589
+ #
590
+ # The above code finds every record with _Bill_ in the First Name field, then deletes the first one.
153
591
  def delete(recid, options = {})
154
592
  get_records('-delete', {'-recid' => recid}, options)
155
593
  return nil
@@ -157,9 +595,49 @@ module Rfm
157
595
 
158
596
  private
159
597
 
598
+ def load
599
+ @loaded = true
600
+ fmpxmllayout = @db.server.load_layout(self).body
601
+ doc = REXML::Document.new(fmpxmllayout)
602
+ root = doc.root
603
+
604
+ # check for errors
605
+ error = root.elements['ERRORCODE'].text.to_i
606
+ raise Rfm::Error::FileMakerError.getError(error) if error != 0
607
+
608
+ # process valuelists
609
+ if root.elements['VALUELISTS'].size > 0
610
+ root.elements['VALUELISTS'].each_element('VALUELIST') { |valuelist|
611
+ name = valuelist.attributes['NAME']
612
+ @value_lists[name] = valuelist.elements.collect {|e| e.text}
613
+ }
614
+ @value_lists.freeze
615
+ end
616
+
617
+ # process field controls
618
+ root.elements['LAYOUT'].each_element('FIELD') { |field|
619
+ name = field.attributes['NAME']
620
+ style = field.elements['STYLE'].attributes['TYPE']
621
+ value_list_name = field.elements['STYLE'].attributes['VALUELIST']
622
+ value_list = @value_lists[value_list_name] if value_list_name != ''
623
+ field_control = FieldControl.new(name, style, value_list_name, value_list)
624
+ existing = @field_controls[name]
625
+ if existing
626
+ if existing.kind_of?(Array)
627
+ existing << field_control
628
+ else
629
+ @field_controls[name] = Array[existing, field_control]
630
+ end
631
+ else
632
+ @field_controls[name] = field_control
633
+ end
634
+ }
635
+ @field_controls.freeze
636
+ end
637
+
160
638
  def get_records(action, extra_params = {}, options = {})
161
639
  Rfm::Result::ResultSet.new(
162
- @db.server, @db.server.do_action(action, params().merge(extra_params), options).body,
640
+ @db.server, @db.server.do_action(@db.account_name, @db.password, action, params().merge(extra_params), options).body,
163
641
  self)
164
642
  end
165
643
 
@@ -168,6 +646,61 @@ module Rfm
168
646
  end
169
647
  end
170
648
 
649
+ # The FieldControl object represents a field on a FileMaker layout. You can find out what field
650
+ # style the field uses, and the value list attached to it.
651
+ #
652
+ # =Attributes
653
+ #
654
+ # * *name* is the name of the field
655
+ #
656
+ # * *style* is any one of:
657
+ # * * :edit_box - a normal editable field
658
+ # * * :scrollable - an editable field with scroll bar
659
+ # * * :popup_menu - a pop-up menu
660
+ # * * :checkbox_set - a set of checkboxes
661
+ # * * :radio_button_set - a set of radio buttons
662
+ # * * :popup_list - a pop-up list
663
+ # * * :calendar - a pop-up calendar
664
+ #
665
+ # * *value_list_name* is the name of the attached value list, if any
666
+ #
667
+ # * *value_list* is an array of strings representing the value list items, or nil
668
+ # if this field has no attached value list
669
+ class FieldControl
670
+ def initialize(name, style, value_list_name, value_list)
671
+ @name = name
672
+ case style
673
+ when "EDITTEXT"
674
+ @style = :edit_box
675
+ when "POPUPMENU"
676
+ @style = :popup_menu
677
+ when "CHECKBOX"
678
+ @style = :checkbox_set
679
+ when "RADIOBUTTONS"
680
+ @style = :radio_button_set
681
+ when "POPUPLIST"
682
+ @style = :popup_list
683
+ when "CALENDAR"
684
+ @style = :calendar
685
+ when "SCROLLTEXT"
686
+ @style = :scrollable
687
+ end
688
+ @value_list_name = value_list_name
689
+ @value_list = value_list
690
+ end
691
+
692
+ attr_reader :name, :style, :value_list_name, :value_list
693
+
694
+ end
695
+
696
+ # The Script object represents a FileMaker script. At this point, the Script object exists only so
697
+ # you can enumrate all scripts in a Database (which is a rare need):
698
+ #
699
+ # myDatabase.script.each {|script|
700
+ # puts script.name
701
+ # }
702
+ #
703
+ # If you want to _run_ a script, see the Layout object instead.
171
704
  class Script
172
705
  def initialize(name, db)
173
706
  @name = name