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.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/.rubocop.yml +17 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +116 -0
- data/Rakefile +6 -0
- data/diagram.png +0 -0
- data/filemaker.gemspec +30 -0
- data/lib/filemaker.rb +13 -0
- data/lib/filemaker/api.rb +13 -0
- data/lib/filemaker/api/query_commands/delete.rb +16 -0
- data/lib/filemaker/api/query_commands/dup.rb +22 -0
- data/lib/filemaker/api/query_commands/edit.rb +26 -0
- data/lib/filemaker/api/query_commands/find.rb +46 -0
- data/lib/filemaker/api/query_commands/findall.rb +34 -0
- data/lib/filemaker/api/query_commands/findany.rb +26 -0
- data/lib/filemaker/api/query_commands/findquery.rb +84 -0
- data/lib/filemaker/api/query_commands/new.rb +21 -0
- data/lib/filemaker/api/query_commands/view.rb +11 -0
- data/lib/filemaker/configuration.rb +28 -0
- data/lib/filemaker/core_ext/hash.rb +32 -0
- data/lib/filemaker/database.rb +29 -0
- data/lib/filemaker/error.rb +391 -0
- data/lib/filemaker/layout.rb +38 -0
- data/lib/filemaker/metadata/field.rb +71 -0
- data/lib/filemaker/record.rb +64 -0
- data/lib/filemaker/resultset.rb +124 -0
- data/lib/filemaker/script.rb +9 -0
- data/lib/filemaker/server.rb +197 -0
- data/lib/filemaker/store/database_store.rb +21 -0
- data/lib/filemaker/store/layout_store.rb +23 -0
- data/lib/filemaker/store/script_store.rb +23 -0
- data/lib/filemaker/version.rb +3 -0
- data/spec/filemaker/api/query_commands/compound_find_spec.rb +69 -0
- data/spec/filemaker/error_spec.rb +257 -0
- data/spec/filemaker/layout_spec.rb +229 -0
- data/spec/filemaker/metadata/field_spec.rb +62 -0
- data/spec/filemaker/record_spec.rb +47 -0
- data/spec/filemaker/resultset_spec.rb +65 -0
- data/spec/filemaker/server_spec.rb +106 -0
- data/spec/filemaker/store/database_store_spec.rb +34 -0
- data/spec/filemaker/store/layout_store_spec.rb +31 -0
- data/spec/filemaker/store/script_store_spec.rb +31 -0
- data/spec/spec_helper.rb +84 -0
- data/spec/support/responses/dbnames.xml +34 -0
- data/spec/support/responses/employment.xml +55 -0
- data/spec/support/responses/jobs.xml +199 -0
- data/spec/support/responses/layoutnames.xml +39 -0
- data/spec/support/responses/portal.xml +108 -0
- data/spec/support/responses/scriptnames.xml +29 -0
- data/spec/support/xml_loader.rb +29 -0
- 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,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
|