filemaker 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +17 -0
  5. data/.travis.yml +4 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +116 -0
  9. data/Rakefile +6 -0
  10. data/diagram.png +0 -0
  11. data/filemaker.gemspec +30 -0
  12. data/lib/filemaker.rb +13 -0
  13. data/lib/filemaker/api.rb +13 -0
  14. data/lib/filemaker/api/query_commands/delete.rb +16 -0
  15. data/lib/filemaker/api/query_commands/dup.rb +22 -0
  16. data/lib/filemaker/api/query_commands/edit.rb +26 -0
  17. data/lib/filemaker/api/query_commands/find.rb +46 -0
  18. data/lib/filemaker/api/query_commands/findall.rb +34 -0
  19. data/lib/filemaker/api/query_commands/findany.rb +26 -0
  20. data/lib/filemaker/api/query_commands/findquery.rb +84 -0
  21. data/lib/filemaker/api/query_commands/new.rb +21 -0
  22. data/lib/filemaker/api/query_commands/view.rb +11 -0
  23. data/lib/filemaker/configuration.rb +28 -0
  24. data/lib/filemaker/core_ext/hash.rb +32 -0
  25. data/lib/filemaker/database.rb +29 -0
  26. data/lib/filemaker/error.rb +391 -0
  27. data/lib/filemaker/layout.rb +38 -0
  28. data/lib/filemaker/metadata/field.rb +71 -0
  29. data/lib/filemaker/record.rb +64 -0
  30. data/lib/filemaker/resultset.rb +124 -0
  31. data/lib/filemaker/script.rb +9 -0
  32. data/lib/filemaker/server.rb +197 -0
  33. data/lib/filemaker/store/database_store.rb +21 -0
  34. data/lib/filemaker/store/layout_store.rb +23 -0
  35. data/lib/filemaker/store/script_store.rb +23 -0
  36. data/lib/filemaker/version.rb +3 -0
  37. data/spec/filemaker/api/query_commands/compound_find_spec.rb +69 -0
  38. data/spec/filemaker/error_spec.rb +257 -0
  39. data/spec/filemaker/layout_spec.rb +229 -0
  40. data/spec/filemaker/metadata/field_spec.rb +62 -0
  41. data/spec/filemaker/record_spec.rb +47 -0
  42. data/spec/filemaker/resultset_spec.rb +65 -0
  43. data/spec/filemaker/server_spec.rb +106 -0
  44. data/spec/filemaker/store/database_store_spec.rb +34 -0
  45. data/spec/filemaker/store/layout_store_spec.rb +31 -0
  46. data/spec/filemaker/store/script_store_spec.rb +31 -0
  47. data/spec/spec_helper.rb +84 -0
  48. data/spec/support/responses/dbnames.xml +34 -0
  49. data/spec/support/responses/employment.xml +55 -0
  50. data/spec/support/responses/jobs.xml +199 -0
  51. data/spec/support/responses/layoutnames.xml +39 -0
  52. data/spec/support/responses/portal.xml +108 -0
  53. data/spec/support/responses/scriptnames.xml +29 -0
  54. data/spec/support/xml_loader.rb +29 -0
  55. metadata +227 -0
@@ -0,0 +1,38 @@
1
+ module Filemaker
2
+ class Layout
3
+ include Api
4
+
5
+ # @return [String] layout name
6
+ attr_reader :name
7
+
8
+ # @return [Filemaker::Server] the server
9
+ attr_reader :server
10
+
11
+ # @return [String] the database
12
+ attr_reader :database
13
+
14
+ def initialize(name, server, database)
15
+ @name = name
16
+ @server = server
17
+ @database = database
18
+ end
19
+
20
+ def default_params
21
+ { '-db' => database.name, '-lay' => name }
22
+ end
23
+
24
+ # @return [Filemaker::Resultset]
25
+ def perform_request(action, args, options)
26
+ response, params = server
27
+ .perform_request(:post, action, default_params.merge(args), options)
28
+
29
+ Filemaker::Resultset.new(server, response.body, params)
30
+ end
31
+
32
+ private
33
+
34
+ def valid_options(options, *keys)
35
+ options.keys.each { |key| options.delete(key) unless keys.include?(key) }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,71 @@
1
+ require 'date'
2
+ require 'bigdecimal'
3
+
4
+ module Filemaker
5
+ module Metadata
6
+ class Field
7
+ # @return [String] name of the field
8
+ attr_reader :name
9
+
10
+ # @return [String] one of 'text', 'number', 'date', 'time',
11
+ # 'timestamp', or 'container'
12
+ attr_reader :data_type
13
+
14
+ # @return [String] one of 'normal', 'calculation', or 'summary'
15
+ attr_reader :field_type
16
+
17
+ # @return [Integer] how many times the <data> repeats
18
+ attr_reader :repeats
19
+
20
+ # @return [Boolean] indicates if field is required or not
21
+ attr_reader :required
22
+
23
+ # @return [Boolean] whether it is a global field
24
+ attr_reader :global
25
+
26
+ def initialize(definition, resultset)
27
+ @name = definition['name']
28
+ @data_type = definition['result']
29
+ @field_type = definition['type']
30
+ @repeats = definition['max-repeat'].to_i
31
+ @global = convert_to_boolean(definition['global'])
32
+ @required = convert_to_boolean(definition['not-empty'])
33
+ @resultset = resultset
34
+ end
35
+
36
+ def coerce(value)
37
+ value = value.to_s.strip
38
+ return nil if value.empty?
39
+
40
+ case data_type
41
+ when 'number'
42
+ BigDecimal.new(value)
43
+ when 'date'
44
+ # date_format likely will be '%m/%d/%Y', but if we got '19/8/2014',
45
+ # then `strptime` will raise invalid date error
46
+ Date.strptime(value, @resultset.date_format)
47
+ # Date.strptime(Date.parse(value).strftime(@resultset.date_format), @resultset.date_format)
48
+ when 'time'
49
+ DateTime.strptime("1/1/-4712 #{value}", @resultset.timestamp_format)
50
+ when 'timestamp'
51
+ DateTime.strptime(value, @resultset.timestamp_format)
52
+ when 'container'
53
+ URI.parse("#{@resultset.server.host}#{value}")
54
+ else
55
+ value
56
+ end
57
+ rescue Exception => e
58
+ msg = "Could not coerce #{value} due to #{e.message}"
59
+ raise Filemaker::Error::CoerceError, msg
60
+ end
61
+
62
+ private
63
+
64
+ # 'yes' != 'no' => true
65
+ # 'no' != 'no' => false
66
+ def convert_to_boolean(value)
67
+ value != 'no'
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,64 @@
1
+ module Filemaker
2
+ class Record < HashWithIndifferentAndCaseInsensitiveAccess
3
+ # @return [String] modification ID
4
+ attr_reader :mod_id
5
+
6
+ # @return [String] record ID that is used for -edit and -delete
7
+ attr_reader :record_id
8
+
9
+ # @return [Hash] additional nested records
10
+ attr_reader :portals
11
+
12
+ def initialize(record, resultset, portal_table_name = nil)
13
+ @mod_id = record['mod-id']
14
+ @record_id = record['record-id']
15
+ @portals = HashWithIndifferentAndCaseInsensitiveAccess.new
16
+
17
+ record.xpath('field').each do |field|
18
+ # `field` is Nokogiri::XML::Element
19
+ field_name = field['name']
20
+ # Right now, I do not want to mess with the field name
21
+ # field_name.gsub!(Regexp.new(portal_table_name + '::'), '')
22
+ # \if portal_table_name
23
+ datum = []
24
+
25
+ metadata_fields = if portal_table_name
26
+ resultset.portal_fields[portal_table_name]
27
+ else
28
+ resultset.fields
29
+ end
30
+
31
+ field.xpath('data').each do |data|
32
+ datum.push(metadata_fields[field_name].coerce(data.inner_text))
33
+ end
34
+
35
+ self[field_name] = normalize_data(datum)
36
+ end
37
+
38
+ build_portals(record.xpath('relatedset'), resultset)
39
+ end
40
+
41
+ private
42
+
43
+ def build_portals(relatedsets, resultset)
44
+ return if relatedsets.empty?
45
+
46
+ relatedsets.each do |relatedset|
47
+ # `relatedset` is Nokogiri::XML::Element
48
+ table_name = relatedset['table']
49
+ records = []
50
+
51
+ relatedset.xpath('record').each do |record|
52
+ records << self.class.new(record, resultset, table_name)
53
+ end
54
+
55
+ @portals[table_name] = records
56
+ end
57
+ end
58
+
59
+ def normalize_data(datum)
60
+ return nil if datum.empty?
61
+ (datum.size == 1) ? datum.first : datum
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,124 @@
1
+ require 'nokogiri'
2
+ require 'filemaker/metadata/field'
3
+
4
+ module Filemaker
5
+ class Resultset
6
+ include Enumerable
7
+
8
+ # @return [Array] hold records
9
+ attr_reader :list
10
+
11
+ # @return [Integer] number of records
12
+ attr_reader :count
13
+
14
+ # @return [Integer] total count of the record
15
+ attr_reader :total_count
16
+
17
+ # @return [Hash] representing the top-level non-portal field-definition
18
+ attr_reader :fields
19
+
20
+ # @return [Hash] representing the portal field-definition
21
+ attr_reader :portal_fields
22
+
23
+ # @return [String] Ruby's date format directive
24
+ attr_reader :date_format
25
+
26
+ # @return [String] Ruby's time format directive
27
+ attr_reader :time_format
28
+
29
+ # @return [String] Ruby's date and time format directive
30
+ attr_reader :timestamp_format
31
+
32
+ # @return [Filemaker::Server] the server
33
+ attr_reader :server
34
+
35
+ # @return [Hash] the request params
36
+ attr_reader :params
37
+
38
+ # @return [String] the raw XML for inspection
39
+ attr_reader :xml
40
+
41
+ # @param xml [Filemaker::Server] server
42
+ # @param xml [String] the XML string from response
43
+ # @param xml [Hash] the request params used to construct request
44
+ def initialize(server, xml, params = nil)
45
+ @list = []
46
+ @fields = {}
47
+ @portal_fields = {}
48
+ @server = server
49
+ @params = params # Useful for debugging
50
+
51
+ doc = Nokogiri::XML(xml).remove_namespaces!
52
+ @xml = doc.to_xml(indent: 2) # Just want to pretty print it
53
+
54
+ error_code = doc.xpath('/fmresultset/error').attribute('code').value.to_i
55
+ raise_potential_error!(error_code)
56
+
57
+ datasource = doc.xpath('/fmresultset/datasource')
58
+ metadata = doc.xpath('/fmresultset/metadata')
59
+ resultset = doc.xpath('/fmresultset/resultset')
60
+ records = resultset.xpath('record')
61
+
62
+ @date_format = convert_format(datasource.attribute('date-format').value)
63
+ @time_format = convert_format(datasource.attribute('time-format').value)
64
+ @timestamp_format = \
65
+ convert_format(datasource.attribute('timestamp-format').value)
66
+
67
+ @count = resultset.attribute('count').value.to_i
68
+ @total_count = datasource.attribute('total-count').value.to_i
69
+
70
+ build_metadata(metadata)
71
+ build_records(records)
72
+ end
73
+
74
+ # Delegate to list -> map, filter, reverse, etc
75
+ def each(*args, &block)
76
+ list.each(*args, &block)
77
+ end
78
+
79
+ private
80
+
81
+ def raise_potential_error!(error_code)
82
+ return if error_code.zero?
83
+
84
+ Filemaker::Error.raise_error_by_code(error_code)
85
+ end
86
+
87
+ def build_metadata(metadata)
88
+ metadata.xpath('field-definition').each do |definition|
89
+ @fields[definition['name']] = Metadata::Field.new(definition, self)
90
+ end
91
+
92
+ metadata.xpath('relatedset-definition').each do |relatedset|
93
+ table_name = relatedset.attribute('table').value
94
+ p_fields = {}
95
+
96
+ relatedset.xpath('field-definition').each do |definition|
97
+ # Right now, I do not want to mess with the field name
98
+ # name = definition['name'].gsub("#{table_name}::", '')
99
+ name = definition['name']
100
+ p_fields[name] = Metadata::Field.new(definition, self)
101
+ end
102
+
103
+ @portal_fields[table_name] = p_fields
104
+ end
105
+ end
106
+
107
+ def build_records(records)
108
+ records.each do |record|
109
+ # record is Nokogiri::XML::Element
110
+ list << Filemaker::Record.new(record, self)
111
+ end
112
+ end
113
+
114
+ def convert_format(format)
115
+ format
116
+ .gsub('MM', '%m')
117
+ .gsub('dd', '%d')
118
+ .gsub('yyyy', '%Y')
119
+ .gsub('HH', '%H')
120
+ .gsub('mm', '%M')
121
+ .gsub('ss', '%S')
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,9 @@
1
+ module Filemaker
2
+ class Script
3
+ def initialize(name, server, database)
4
+ @name = name
5
+ @server = server
6
+ @database = database
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,197 @@
1
+ require 'faraday'
2
+ require 'typhoeus/adapters/faraday'
3
+ require 'filemaker/configuration'
4
+
5
+ module Filemaker
6
+ class Server
7
+ extend Forwardable
8
+
9
+ # @return [Faraday::Connection] the HTTP connection
10
+ attr_reader :connection
11
+
12
+ # @return [Filemaker::Store::DatabaseStore] the database store
13
+ attr_reader :databases
14
+ alias_method :database, :databases
15
+ alias_method :db, :databases
16
+
17
+ def_delegators :@config, :host, :url, :ssl, :endpoint
18
+ def_delegators :@config, :account_name, :password
19
+
20
+ def initialize(options = {})
21
+ @config = Configuration.new
22
+ yield @config if block_given?
23
+ fail ArgumentError if @config.not_configurable?
24
+
25
+ @databases = Store::DatabaseStore.new(self)
26
+ @connection = get_connection(options)
27
+ end
28
+
29
+ # @api private
30
+ # Mostly used by Filemaker::Api
31
+ # TODO: There must be tracing/instrumentation. CURL etc.
32
+ # Or performance metrics?
33
+ # Also we want to pass in timeout option so we can ignore timeout for really
34
+ # long requests
35
+ #
36
+ # @return [Array] Faraday::Response and request params Hash
37
+ def perform_request(method, action, args, options = {})
38
+ params = serialize_args(args)
39
+ .merge(expand_options(options))
40
+ .merge({ action => '' })
41
+
42
+ # Serialize the params for submission
43
+ params = params.stringify_keys
44
+
45
+ log_action(params)
46
+
47
+ # yield params if block_given?
48
+ response = @connection.__send__(method, endpoint, params)
49
+
50
+ case response.status
51
+ when 200 then [response, params]
52
+ when 401 then fail Error::AuthenticationError, 'Auth failed.'
53
+ when 0 then fail Error::CommunicationError, 'Empty response.'
54
+ when 404 then fail Error::CommunicationError, 'HTTP 404 Not Found'
55
+ when 302 then fail Error::CommunicationError, 'Redirect not supported'
56
+ else
57
+ msg = "Unknown response status = #{response.status}"
58
+ fail Error::CommunicationError, msg
59
+ end
60
+ end
61
+
62
+ def handler_names
63
+ @connection.builder.handlers.map(&:name)
64
+ end
65
+
66
+ private
67
+
68
+ def get_connection(options = {})
69
+ faraday_options = @config.connection_options.merge(options)
70
+
71
+ Faraday.new(@config.url, faraday_options) do |faraday|
72
+ faraday.request :url_encoded
73
+ faraday.adapter :typhoeus
74
+ faraday.headers[:user_agent] = \
75
+ "filemaker-ruby-#{Filemaker::VERSION}".freeze
76
+ faraday.basic_auth @config.account_name, @config.password
77
+ end
78
+ end
79
+
80
+ def serialize_args(args)
81
+ return {} if args.nil?
82
+
83
+ args.each do |key, value|
84
+ case value
85
+ when Date then args[key] = value.strftime('%m/%d/%Y')
86
+ when DateTime then args[key] = value.strftime('%m/%d/%Y %H:%M:%S')
87
+ when Time then args[key] = value.strftime('%H:%M')
88
+ else
89
+ args[key] = value.to_s
90
+ end
91
+ end
92
+
93
+ args
94
+ end
95
+
96
+ def expand_options(options)
97
+ expanded = {}
98
+ options.each do |key, value|
99
+ case key
100
+ when :max
101
+ expanded['-max'] = value
102
+ when :skip
103
+ expanded['-skip'] = value
104
+ when :sortfield
105
+ if value.is_a? Array
106
+ msg = 'Too many sortfield, limit=9'
107
+ fail(Filemaker::Error::ParameterError, msg) if value.size > 9
108
+ value.each_index do |index|
109
+ expanded["-sortfield.#{index + 1}"] = value[index]
110
+ end
111
+ else
112
+ expanded['-sortfield.1'] = value
113
+ end
114
+ when :sortorder
115
+ if value.is_a? Array
116
+ # Use :sortfield as single source of truth for array size
117
+ msg = 'Too many sortorder, limit=9'
118
+ fail(Filemaker::Error::ParameterError, msg) if value.size > 9
119
+ options[:sortfield].each_index do |index|
120
+ expanded["-sortorder.#{index + 1}"] = value[index] || 'ascend'
121
+ end
122
+ else
123
+ expanded['-sortorder.1'] = value
124
+ end
125
+ when :lay_response
126
+ expanded['-lay.response'] = value
127
+ when :lop
128
+ expanded['-lop'] = value
129
+ when :modid
130
+ expanded['-modid'] = value
131
+ when :relatedsets_filter
132
+ expanded['-relatedsets.filter'] = value
133
+ when :relatedsets_max
134
+ expanded['-relatedsets.max'] = value
135
+ when :delete_related
136
+ expanded['-delete.related'] = value
137
+ when :script
138
+ if value.is_a? Array
139
+ expanded['-script'] = value[0]
140
+ expanded['-script.param'] = value[1]
141
+ else
142
+ expanded['-script'] = value
143
+ end
144
+ when :script_prefind
145
+ if value.is_a? Array
146
+ expanded['-script.prefind'] = value[0]
147
+ expanded['-script.prefind.param'] = value[1]
148
+ else
149
+ expanded['-script.prefind'] = value
150
+ end
151
+ when :script_presort
152
+ if value.is_a? Array
153
+ expanded['-script.presort'] = value[0]
154
+ expanded['-script.presort.param'] = value[1]
155
+ else
156
+ expanded['-script.presort'] = value
157
+ end
158
+ end
159
+ end
160
+
161
+ expanded
162
+ end
163
+
164
+ def log_action(params)
165
+ case @config.log
166
+ when :simple then log_simple(params)
167
+ when :curl then log_curl(params)
168
+ when :curl_auth then log_curl(params, true)
169
+ else
170
+ return
171
+ end
172
+ end
173
+
174
+ def log_curl(params, has_auth = false)
175
+ full_url = "#{url}#{endpoint}?#{log_params(params)}"
176
+ curl_ssl_option, auth = '', ''
177
+
178
+ curl_ssl_option = ' -k' if ssl.is_a?(Hash) && !ssl.fetch(:verify) { true }
179
+
180
+ auth = " -H 'Authorization: #{@connection.headers['Authorization']}'" if \
181
+ has_auth
182
+
183
+ warn 'Pretty print like so: `curl XXX | xmllint --format -`'
184
+ warn "curl -XGET '#{full_url}'#{curl_ssl_option} -i#{auth}"
185
+ end
186
+
187
+ def log_simple(params)
188
+ warn "#{endpoint}?#{log_params(params)}"
189
+ end
190
+
191
+ def log_params(params)
192
+ params.map do |key, value|
193
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
194
+ end.join('&')
195
+ end
196
+ end
197
+ end