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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +129 -17
  3. data/filemaker.gemspec +1 -0
  4. data/lib/filemaker.rb +47 -0
  5. data/lib/filemaker/api/query_commands/findquery.rb +20 -3
  6. data/lib/filemaker/configuration.rb +1 -1
  7. data/lib/filemaker/core_ext/hash.rb +19 -15
  8. data/lib/filemaker/error.rb +5 -0
  9. data/lib/filemaker/metadata/field.rb +21 -5
  10. data/lib/filemaker/model.rb +132 -0
  11. data/lib/filemaker/model/builder.rb +52 -0
  12. data/lib/filemaker/model/components.rb +25 -0
  13. data/lib/filemaker/model/criteria.rb +101 -0
  14. data/lib/filemaker/model/field.rb +38 -0
  15. data/lib/filemaker/model/fields.rb +80 -0
  16. data/lib/filemaker/model/findable.rb +35 -0
  17. data/lib/filemaker/model/optional.rb +69 -0
  18. data/lib/filemaker/model/pagination.rb +41 -0
  19. data/lib/filemaker/model/persistable.rb +96 -0
  20. data/lib/filemaker/model/relations.rb +72 -0
  21. data/lib/filemaker/model/relations/belongs_to.rb +30 -0
  22. data/lib/filemaker/model/relations/has_many.rb +79 -0
  23. data/lib/filemaker/model/relations/proxy.rb +35 -0
  24. data/lib/filemaker/model/selectable.rb +146 -0
  25. data/lib/filemaker/railtie.rb +17 -0
  26. data/lib/filemaker/record.rb +25 -0
  27. data/lib/filemaker/resultset.rb +12 -4
  28. data/lib/filemaker/server.rb +7 -5
  29. data/lib/filemaker/version.rb +1 -1
  30. data/spec/filemaker/api/query_commands/compound_find_spec.rb +13 -1
  31. data/spec/filemaker/configuration_spec.rb +23 -0
  32. data/spec/filemaker/layout_spec.rb +0 -1
  33. data/spec/filemaker/model/criteria_spec.rb +304 -0
  34. data/spec/filemaker/model/relations_spec.rb +85 -0
  35. data/spec/filemaker/model_spec.rb +73 -0
  36. data/spec/filemaker/record_spec.rb +12 -1
  37. data/spec/spec_helper.rb +1 -0
  38. data/spec/support/filemaker.yml +13 -0
  39. data/spec/support/models.rb +38 -0
  40. 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