rfm 0.2.0 → 1.0.0

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