filemaker 0.0.1

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.
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