filemaker 0.0.1 → 0.0.2
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 +4 -4
- data/README.md +129 -17
- data/filemaker.gemspec +1 -0
- data/lib/filemaker.rb +47 -0
- data/lib/filemaker/api/query_commands/findquery.rb +20 -3
- data/lib/filemaker/configuration.rb +1 -1
- data/lib/filemaker/core_ext/hash.rb +19 -15
- data/lib/filemaker/error.rb +5 -0
- data/lib/filemaker/metadata/field.rb +21 -5
- data/lib/filemaker/model.rb +132 -0
- data/lib/filemaker/model/builder.rb +52 -0
- data/lib/filemaker/model/components.rb +25 -0
- data/lib/filemaker/model/criteria.rb +101 -0
- data/lib/filemaker/model/field.rb +38 -0
- data/lib/filemaker/model/fields.rb +80 -0
- data/lib/filemaker/model/findable.rb +35 -0
- data/lib/filemaker/model/optional.rb +69 -0
- data/lib/filemaker/model/pagination.rb +41 -0
- data/lib/filemaker/model/persistable.rb +96 -0
- data/lib/filemaker/model/relations.rb +72 -0
- data/lib/filemaker/model/relations/belongs_to.rb +30 -0
- data/lib/filemaker/model/relations/has_many.rb +79 -0
- data/lib/filemaker/model/relations/proxy.rb +35 -0
- data/lib/filemaker/model/selectable.rb +146 -0
- data/lib/filemaker/railtie.rb +17 -0
- data/lib/filemaker/record.rb +25 -0
- data/lib/filemaker/resultset.rb +12 -4
- data/lib/filemaker/server.rb +7 -5
- data/lib/filemaker/version.rb +1 -1
- data/spec/filemaker/api/query_commands/compound_find_spec.rb +13 -1
- data/spec/filemaker/configuration_spec.rb +23 -0
- data/spec/filemaker/layout_spec.rb +0 -1
- data/spec/filemaker/model/criteria_spec.rb +304 -0
- data/spec/filemaker/model/relations_spec.rb +85 -0
- data/spec/filemaker/model_spec.rb +73 -0
- data/spec/filemaker/record_spec.rb +12 -1
- data/spec/spec_helper.rb +1 -0
- data/spec/support/filemaker.yml +13 -0
- data/spec/support/models.rb +38 -0
- metadata +44 -2
@@ -0,0 +1,96 @@
|
|
1
|
+
module Filemaker
|
2
|
+
module Model
|
3
|
+
module Persistable
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
define_model_callbacks :save, :create, :update, :destroy
|
8
|
+
end
|
9
|
+
|
10
|
+
# Call save! but do not raise error.
|
11
|
+
def save
|
12
|
+
save!
|
13
|
+
rescue
|
14
|
+
errors.add(:base) << $! # Does this works?
|
15
|
+
nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def save!
|
19
|
+
run_callbacks :save do
|
20
|
+
new_record? ? create : update
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def create
|
25
|
+
return false unless valid?
|
26
|
+
|
27
|
+
run_callbacks :create do
|
28
|
+
options = {}
|
29
|
+
yield options if block_given?
|
30
|
+
resultset = api.new(fm_attributes, options)
|
31
|
+
replace_new_data(resultset)
|
32
|
+
end
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
def update
|
37
|
+
return false unless valid?
|
38
|
+
|
39
|
+
run_callbacks :update do
|
40
|
+
# Will raise `RecordModificationIdMismatchError` if does not match
|
41
|
+
options = { modid: mod_id } # Always pass in?
|
42
|
+
yield options if block_given?
|
43
|
+
resultset = api.edit(record_id, fm_attributes, options)
|
44
|
+
replace_new_data(resultset)
|
45
|
+
end
|
46
|
+
self
|
47
|
+
end
|
48
|
+
|
49
|
+
def update_attributes(attrs = {})
|
50
|
+
return self if attrs.blank?
|
51
|
+
assign_attributes(attrs)
|
52
|
+
save
|
53
|
+
end
|
54
|
+
|
55
|
+
# Use -delete to remove the record backed by the model.
|
56
|
+
# @return [Filemaker::Model] frozen instance
|
57
|
+
def destroy
|
58
|
+
return if new_record?
|
59
|
+
|
60
|
+
run_callbacks :destroy do
|
61
|
+
options = {}
|
62
|
+
yield options if block_given?
|
63
|
+
api.delete(record_id, options)
|
64
|
+
end
|
65
|
+
freeze
|
66
|
+
end
|
67
|
+
alias_method :delete, :destroy
|
68
|
+
|
69
|
+
def assign_attributes(new_attributes)
|
70
|
+
return if new_attributes.blank?
|
71
|
+
new_attributes.each_pair do |key, value|
|
72
|
+
public_send("#{key}=", value) if respond_to?("#{key}=")
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# If you have calculated field from FileMaker, it will be replaced.
|
79
|
+
def replace_new_data(resultset)
|
80
|
+
record = resultset.first
|
81
|
+
|
82
|
+
@new_record = false
|
83
|
+
@record_id = record.record_id
|
84
|
+
@mod_id = record.mod_id
|
85
|
+
|
86
|
+
record.keys.each do |fm_field_name|
|
87
|
+
# record.keys are all lowercase
|
88
|
+
field = self.class.find_field_by_name(fm_field_name)
|
89
|
+
next unless field
|
90
|
+
|
91
|
+
public_send("#{field.name}=", record[fm_field_name])
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'filemaker/model/relations/belongs_to'
|
2
|
+
require 'filemaker/model/relations/has_many'
|
3
|
+
|
4
|
+
module Filemaker
|
5
|
+
module Model
|
6
|
+
# Model relationships such as has_many, belongs_to, and has_portal.
|
7
|
+
module Relations
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
attr_reader :relations
|
12
|
+
end
|
13
|
+
|
14
|
+
module ClassMethods
|
15
|
+
def has_many(name, options = {})
|
16
|
+
relate_collection(Relations::HasMany, name, options)
|
17
|
+
end
|
18
|
+
|
19
|
+
def belongs_to(name, options = {})
|
20
|
+
relate_single(Relations::BelongsTo, name, options)
|
21
|
+
end
|
22
|
+
|
23
|
+
def has_portal(name, options = {})
|
24
|
+
Relations::HasPortal.new(self, name, options)
|
25
|
+
end
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
# Get the single model and cache it to `relations`
|
30
|
+
def relate_single(type, name, options)
|
31
|
+
name = name.to_s
|
32
|
+
|
33
|
+
# Reader
|
34
|
+
#
|
35
|
+
# @example Reload the record
|
36
|
+
# job.company(true)
|
37
|
+
define_method(name) do |force_reload = false|
|
38
|
+
if force_reload
|
39
|
+
@relations[name] = type.new(self, name, options)
|
40
|
+
else
|
41
|
+
@relations[name] ||= type.new(self, name, options)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Writer
|
46
|
+
#
|
47
|
+
# TODO: What happen if `object` is brand new? We would want to save
|
48
|
+
# the child as well as the parent. We need to wait for the child to
|
49
|
+
# save and return the identity ID, then we update the parent's
|
50
|
+
# reference_key.
|
51
|
+
define_method("#{name}=") do |object|
|
52
|
+
params = { "#{name}_id" => object.public_send("#{name}_id") }
|
53
|
+
update_attributes(params)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Creator
|
57
|
+
# define_method("create_#{name}") do |attrs = {}|
|
58
|
+
# end
|
59
|
+
end
|
60
|
+
|
61
|
+
# For collection, we will return criteria and not cache anything.
|
62
|
+
def relate_collection(type, name, options)
|
63
|
+
name = name.to_s
|
64
|
+
|
65
|
+
define_method(name) do
|
66
|
+
type.new(self, name, options)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'filemaker/model/relations/proxy'
|
2
|
+
|
3
|
+
module Filemaker
|
4
|
+
module Model
|
5
|
+
module Relations
|
6
|
+
class BelongsTo < Proxy
|
7
|
+
def initialize(owner, name, options)
|
8
|
+
super(owner, name, options)
|
9
|
+
build_target
|
10
|
+
end
|
11
|
+
|
12
|
+
def reference_key
|
13
|
+
options.fetch(:reference_key) { "#{@name}_id" }
|
14
|
+
end
|
15
|
+
|
16
|
+
protected
|
17
|
+
|
18
|
+
def build_target
|
19
|
+
key_value = owner.public_send(reference_key.to_sym)
|
20
|
+
|
21
|
+
if key_value.blank?
|
22
|
+
@target = nil
|
23
|
+
else
|
24
|
+
@target = target_class.where(reference_key => "=#{key_value}").first
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'filemaker/model/relations/proxy'
|
2
|
+
|
3
|
+
module Filemaker
|
4
|
+
module Model
|
5
|
+
module Relations
|
6
|
+
class HasMany < Proxy
|
7
|
+
def initialize(owner, name, options)
|
8
|
+
super(owner, name, options)
|
9
|
+
build_target
|
10
|
+
end
|
11
|
+
|
12
|
+
# If no reference_key, we will use owner's identity field. If there is
|
13
|
+
# no identity, we will...??
|
14
|
+
def reference_key
|
15
|
+
options.fetch(:reference_key) { owner.identity.name }
|
16
|
+
end
|
17
|
+
|
18
|
+
# Append a model or array of models to the relation. Will set the owner
|
19
|
+
# ID to the children.
|
20
|
+
#
|
21
|
+
# @example Append a model
|
22
|
+
# job.applicants << applicant
|
23
|
+
#
|
24
|
+
# @example Array of models
|
25
|
+
# job.applicants << [applicant_a, applicant_b, applicant_c]
|
26
|
+
#
|
27
|
+
# @param [Filemaker::Model, Array<Filemaker::Model>] *args
|
28
|
+
def <<(*args)
|
29
|
+
docs = args.flatten
|
30
|
+
return concat(docs) if docs.size > 1
|
31
|
+
if (doc = docs.first)
|
32
|
+
create(doc)
|
33
|
+
end
|
34
|
+
self
|
35
|
+
end
|
36
|
+
alias_method :push, :<<
|
37
|
+
|
38
|
+
# def concat(docs)
|
39
|
+
# # TODO: Find out how to do batch insert in FileMaker
|
40
|
+
# end
|
41
|
+
|
42
|
+
# Build a single model. The owner will be linked, but the record will
|
43
|
+
# not be saved.
|
44
|
+
#
|
45
|
+
# @example Append a model
|
46
|
+
# job.applicants.build(name: 'Bob')
|
47
|
+
# job.save
|
48
|
+
#
|
49
|
+
# @param [Hash] attrs The attributes for the fields
|
50
|
+
#
|
51
|
+
# @return [Filemaker::Model] the actual model
|
52
|
+
def build(attrs = {})
|
53
|
+
attrs.merge!(owner.identity.name => owner.identity_id) if \
|
54
|
+
owner.identity_id
|
55
|
+
target_class.new(attrs)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Same as `build`, except that it will be saved automatically.
|
59
|
+
#
|
60
|
+
# @return [Filemaker::Model] the actual saved model
|
61
|
+
def create(attrs = {})
|
62
|
+
build(attrs).save
|
63
|
+
end
|
64
|
+
|
65
|
+
protected
|
66
|
+
|
67
|
+
def build_target
|
68
|
+
key_value = owner.public_send(reference_key.to_sym)
|
69
|
+
|
70
|
+
if key_value.blank?
|
71
|
+
@target = nil # Or should we return empty array?
|
72
|
+
else
|
73
|
+
@target = target_class.where(reference_key => "=#{key_value}")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Filemaker
|
2
|
+
module Model
|
3
|
+
module Relations
|
4
|
+
# A proxy is a class to send all unknown methods to it's target. The
|
5
|
+
# target here will be the eventual associated model.
|
6
|
+
class Proxy
|
7
|
+
instance_methods.each do |method|
|
8
|
+
undef_method(method) unless
|
9
|
+
method =~ /(^__|^send|^object_id|^respond_to|^tap|^extend)/
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_accessor :owner, :target, :options
|
13
|
+
|
14
|
+
# @param [Filemaker::Layout] owner The instance of the model
|
15
|
+
# @param [String] name The relationship name
|
16
|
+
# @param [Hash] options Relationship options
|
17
|
+
def initialize(owner, name, options)
|
18
|
+
@owner = owner
|
19
|
+
@name = name
|
20
|
+
@options = options
|
21
|
+
@class_name = options.fetch(:class_name) { name.to_s.classify }
|
22
|
+
end
|
23
|
+
|
24
|
+
def target_class
|
25
|
+
return @class_name if @class_name.is_a?(Class)
|
26
|
+
@class_name.constantize
|
27
|
+
end
|
28
|
+
|
29
|
+
def method_missing(name, *args, &block)
|
30
|
+
target.send(name, *args, &block)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
module Filemaker
|
2
|
+
module Model
|
3
|
+
module Selectable
|
4
|
+
# Find records based on query hash.
|
5
|
+
#
|
6
|
+
# @param [Hash] criterion Hash criterion
|
7
|
+
#
|
8
|
+
# @return [Filemaker::Model::Criteria]
|
9
|
+
def where(criterion)
|
10
|
+
fail Filemaker::Error::MixedClauseError,
|
11
|
+
"Can't mix 'where' with 'in'." if chains.include?(:in)
|
12
|
+
chains.push(:where)
|
13
|
+
|
14
|
+
@selector ||= {}
|
15
|
+
selector.merge!(klass.with_model_fields(criterion))
|
16
|
+
yield options if block_given?
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
# Find records based on model ID. If passed a hash, will use `where`.
|
21
|
+
# On the last resort, if we seriously can't find using `where`, we find
|
22
|
+
# it thru the `recid`. Is this a good design? We will see in production.
|
23
|
+
# Performance note: 2 HTTP requests if going that last resort route.
|
24
|
+
#
|
25
|
+
# @example Find by model ID.
|
26
|
+
# Model.find('CAID324')
|
27
|
+
#
|
28
|
+
# @example Find with a Hash. This will delegate to `where`.
|
29
|
+
# Model.find(name: 'Bob', salary: 4000)
|
30
|
+
#
|
31
|
+
# @param [Integer, String, Hash] criterion
|
32
|
+
#
|
33
|
+
# @return [Filemaker::Model::Criteria, Filemaker::Model]
|
34
|
+
def find(criterion)
|
35
|
+
return where(criterion) if criterion.is_a? Hash
|
36
|
+
|
37
|
+
# Find using model ID (may not be the -recid)
|
38
|
+
id = criterion.to_s.gsub(/\A=*/, '=') # Always append '=' for ID
|
39
|
+
|
40
|
+
# If we are finding with ID, we just limit to one and return
|
41
|
+
# immediately. Last resort is to use the recid to find.
|
42
|
+
where(klass.identity.name => id).first || recid(criterion)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Using FileMaker's internal ID to find the record.
|
46
|
+
def recid(id)
|
47
|
+
return nil if id.blank?
|
48
|
+
|
49
|
+
@selector ||= {}
|
50
|
+
selector['-recid'] = id
|
51
|
+
chains.push(:where)
|
52
|
+
first
|
53
|
+
end
|
54
|
+
|
55
|
+
%w(eq cn bw ew gt gte lt lte neq).each do |operator|
|
56
|
+
define_method(operator) do |criterion, &block|
|
57
|
+
fail Filemaker::Error::MixedClauseError,
|
58
|
+
"Can't mix 'where' with 'in'." if chains.include?(:in)
|
59
|
+
chains.push(operator.to_sym)
|
60
|
+
chains.push(:where) unless chains.include?(:where) # Just one time
|
61
|
+
@selector ||= {}
|
62
|
+
|
63
|
+
if operator == 'bw'
|
64
|
+
criterion = klass.with_model_fields(criterion, false)
|
65
|
+
else
|
66
|
+
criterion = klass.with_model_fields(criterion)
|
67
|
+
end
|
68
|
+
|
69
|
+
criterion.each_key do |key|
|
70
|
+
selector["#{key}.op"] = operator
|
71
|
+
end
|
72
|
+
|
73
|
+
selector.merge!(criterion)
|
74
|
+
|
75
|
+
# Inside define_method, we cannot have yield or block_given?, so we
|
76
|
+
# just use &block
|
77
|
+
block.call(options) if block
|
78
|
+
self
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
alias_method :equals, :eq
|
83
|
+
alias_method :contains, :cn
|
84
|
+
alias_method :begins_with, :bw
|
85
|
+
alias_method :ends_with, :ew
|
86
|
+
alias_method :not, :neq
|
87
|
+
|
88
|
+
# Find records based on FileMaker's compound find syntax.
|
89
|
+
#
|
90
|
+
# @example Find using a single hash
|
91
|
+
# Model.in(nationality: %w(Singapore Malaysia))
|
92
|
+
#
|
93
|
+
# @example Find using an array of hashes
|
94
|
+
# Model.in([{nationality: %w(Singapore Malaysia)}, {age: [20, 30]}])
|
95
|
+
#
|
96
|
+
# @param [Hash, Array]
|
97
|
+
#
|
98
|
+
# @return [Filemaker::Model::Criteria]
|
99
|
+
def in(criterion, negating = false)
|
100
|
+
fail Filemaker::Error::MixedClauseError,
|
101
|
+
"Can't mix 'in' with 'where'." if chains.include?(:where)
|
102
|
+
chains.push(:in)
|
103
|
+
@selector ||= []
|
104
|
+
|
105
|
+
become_array(criterion).each do |hash|
|
106
|
+
accepted_hash = klass.with_model_fields(hash)
|
107
|
+
accepted_hash.merge!('-omit' => true) if negating
|
108
|
+
@selector << accepted_hash
|
109
|
+
end
|
110
|
+
|
111
|
+
yield options if block_given?
|
112
|
+
self
|
113
|
+
end
|
114
|
+
|
115
|
+
# Simply append '-omit' => true to all criteria
|
116
|
+
def not_in(criterion)
|
117
|
+
self.in(criterion, true)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Used with `where` to specify how the queries are combined. Default is
|
121
|
+
# 'and', so you won't find any `and` method.
|
122
|
+
#
|
123
|
+
# @example Mix with where to 'or' query
|
124
|
+
# Model.where(name: 'Bob').or(age: '50')
|
125
|
+
#
|
126
|
+
# @param [Hash] criterion Hash criterion
|
127
|
+
#
|
128
|
+
# @return [Filemaker::Model::Criteria]
|
129
|
+
def or(criterion)
|
130
|
+
fail Filemaker::Error::MixedClauseError,
|
131
|
+
"Can't mix 'or' with 'in'." if chains.include?(:in)
|
132
|
+
@selector ||= {}
|
133
|
+
selector.merge!(klass.with_model_fields(criterion))
|
134
|
+
options[:lop] = 'or'
|
135
|
+
yield options if block_given?
|
136
|
+
self
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def become_array(value)
|
142
|
+
value.is_a?(Array) ? value : [value]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|