fmrest-spyke 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ # Modifies Spyke models to use `__record_id` instead of `id` as the
7
+ # "primary key" method, so that we can map a model class to a FM layout
8
+ # with a field named `id` without clobbering it.
9
+ #
10
+ # The `id` reader method still maps to the record ID for backwards
11
+ # compatibility and because Spyke hardcodes its use at various points
12
+ # through its codebase, but it can be safely overwritten (e.g. to map to
13
+ # a FM field).
14
+ #
15
+ # The recommended way to deal with a layout that maps an `id` attribute
16
+ # is to remap it in the model to something else, e.g. `unique_id`.
17
+ #
18
+ module RecordID
19
+ extend ::ActiveSupport::Concern
20
+
21
+ included do
22
+ # @return [Integer] the record's recordId
23
+ attr_reader :__record_id
24
+ alias_method :record_id, :__record_id
25
+ alias_method :id, :__record_id
26
+
27
+ # @return [Integer] the record's modId
28
+ attr_reader :__mod_id
29
+ alias_method :mod_id, :__mod_id
30
+
31
+ # Get rid of Spyke's id= setter method, as we'll be using __record_id=
32
+ # instead
33
+ undef_method :id=
34
+
35
+ # Tell Spyke that we want __record_id as the PK
36
+ self.primary_key = :__record_id
37
+ end
38
+
39
+ # Sets the recordId and converts it to integer if it's not nil
40
+ #
41
+ # @param value [String, Integer, nil] The new recordId
42
+ #
43
+ # @return [Integer] the record's recordId
44
+ def __record_id=(value)
45
+ @__record_id = value.nil? ? nil : value.to_i
46
+ end
47
+
48
+ # Sets the modId and converts it to integer if it's not nil
49
+ #
50
+ # @param value [String, Integer, nil] The new modId
51
+ #
52
+ # @return [Integer] the record's modId
53
+ def __mod_id=(value)
54
+ @__mod_id = value.nil? ? nil : value.to_i
55
+ end
56
+
57
+ def __record_id?
58
+ __record_id.present?
59
+ end
60
+ alias_method :record_id?, :__record_id?
61
+ alias_method :persisted?, :__record_id?
62
+
63
+ # Spyke override -- Use `__record_id` instead of `id`
64
+ #
65
+ def hash
66
+ __record_id.hash
67
+ end
68
+
69
+ # Spyke override -- Renders class string with layout name and
70
+ # `record_id`.
71
+ #
72
+ # @return [String] A string representation of the class
73
+ #
74
+ def inspect
75
+ "#<#{self.class}(layout: #{self.class.layout}) record_id: #{__record_id.inspect} #{inspect_attributes}>"
76
+ end
77
+
78
+ # Spyke override -- Use `__record_id` instead of `id`
79
+ #
80
+ # @param id [Integer] The id of the record to destroy
81
+ #
82
+ def destroy(id = nil)
83
+ new(__record_id: id).destroy
84
+ end
85
+
86
+ private
87
+
88
+ # Spyke override (private)
89
+ #
90
+ def conflicting_ids?(attributes)
91
+ false
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module Serialization
7
+ FM_DATE_FORMAT = "%m/%d/%Y"
8
+ FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S"
9
+
10
+ # Spyke override -- Return FM Data API's expected JSON format,
11
+ # including only modified fields.
12
+ #
13
+ def to_params
14
+ params = {
15
+ fieldData: serialize_values!(changed_params_not_embedded_in_url).merge(serialize_portal_deletions)
16
+ }
17
+
18
+ params[:modId] = __mod_id.to_s if __mod_id
19
+
20
+ portal_data = serialize_portals
21
+
22
+ params[:portalData] = portal_data unless portal_data.empty?
23
+
24
+ params
25
+ end
26
+
27
+ protected
28
+
29
+ def serialize_for_portal(portal)
30
+ params =
31
+ changed_params.except(:__record_id).transform_keys do |key|
32
+ "#{portal.attribute_prefix}::#{key}"
33
+ end
34
+
35
+ params[:recordId] = __record_id.to_s if __record_id
36
+ params[:modId] = __mod_id.to_s if __mod_id
37
+
38
+ serialize_values!(params)
39
+ end
40
+
41
+ private
42
+
43
+ def serialize_portals
44
+ portal_data = {}
45
+
46
+ portals.each do |portal|
47
+ portal.each do |portal_record|
48
+ next unless portal_record.changed? && !portal_record.marked_for_destruction?
49
+ portal_params = portal_data[portal.portal_key] ||= []
50
+ portal_params << portal_record.serialize_for_portal(portal)
51
+ end
52
+ end
53
+
54
+ portal_data
55
+ end
56
+
57
+ def serialize_portal_deletions
58
+ deletions = []
59
+
60
+ portals.each do |portal|
61
+ portal.select(&:marked_for_destruction?).each do |portal_record|
62
+ next unless portal_record.persisted?
63
+ deletions << "#{portal.portal_key}.#{portal_record.__record_id}"
64
+ end
65
+ end
66
+
67
+ return {} if deletions.length == 0
68
+
69
+ { deleteRelated: deletions.length == 1 ? deletions.first : deletions }
70
+ end
71
+
72
+ def changed_params_not_embedded_in_url
73
+ params_not_embedded_in_url.slice(*mapped_changed)
74
+ end
75
+
76
+ # Modifies the given hash in-place encoding non-string values (e.g.
77
+ # dates) to their string representation when appropriate.
78
+ #
79
+ def serialize_values!(params)
80
+ params.transform_values! do |value|
81
+ case value
82
+ when *datetime_classes
83
+ convert_datetime_timezone(value.to_datetime).strftime(FM_DATETIME_FORMAT)
84
+ when *date_classes
85
+ value.strftime(FM_DATE_FORMAT)
86
+ else
87
+ value
88
+ end
89
+ end
90
+
91
+ params
92
+ end
93
+
94
+ def convert_datetime_timezone(dt)
95
+ case fmrest_config.timezone
96
+ when :utc, "utc"
97
+ dt.new_offset(0)
98
+ when :local, "local"
99
+ dt.new_offset(FmRest::V1.local_offset_for_datetime(dt))
100
+ when nil
101
+ dt
102
+ end
103
+ end
104
+
105
+ def datetime_classes
106
+ [DateTime, Time, defined?(FmRest::StringDateTime) && FmRest::StringDateTime].compact
107
+ end
108
+
109
+ def date_classes
110
+ [Date, defined?(FmRest::StringDate) && FmRest::StringDate].compact
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module URI
7
+ extend ::ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Accessor for FM layout (helps with building the URI)
11
+ #
12
+ def layout(layout = nil)
13
+ @layout = layout if layout
14
+ @layout ||= model_name.name
15
+ end
16
+
17
+ # Spyke override -- Extends `uri` to default to FM Data's URI schema
18
+ #
19
+ def uri(uri_template = nil)
20
+ if @uri.nil? && uri_template.nil?
21
+ return FmRest::V1.record_path(layout) + "(/:#{primary_key})"
22
+ end
23
+ super
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ # Extend Spyke's HasMany association with custom options
6
+ #
7
+ class Portal < ::Spyke::Associations::HasMany
8
+ def initialize(*args)
9
+ super
10
+
11
+ # Portals are always embedded, so no special URI
12
+ @options[:uri] = ""
13
+ end
14
+
15
+ def portal_key
16
+ (@options[:portal_key] || name).to_s
17
+ end
18
+
19
+ def attribute_prefix
20
+ (@options[:attribute_prefix] || portal_key).to_s
21
+ end
22
+
23
+ # Callback method, not meant to be used directly
24
+ #
25
+ def parent_changes_applied
26
+ each(&:changes_applied)
27
+ end
28
+
29
+ def <<(*records)
30
+ records.flatten.each { |r| add_to_parent(r) }
31
+ self
32
+ end
33
+ alias_method :push, :<<
34
+ alias_method :concat, :<<
35
+
36
+ def _remove_marked_for_destruction
37
+ find_some.reject!(&:marked_for_destruction?)
38
+ end
39
+
40
+ private
41
+
42
+ # Spyke::Associations::HasMany#initialize calls primary_key to build the
43
+ # default URI, which causes a NameError, so this is here just to prevent
44
+ # that. We don't care what it returns as we override the URI with nil
45
+ # anyway
46
+ def primary_key; end
47
+
48
+ # Make sure the association doesn't try to fetch records through URI
49
+ def uri; nil; end
50
+
51
+ def embedded_data
52
+ parent.attributes[portal_key]
53
+ end
54
+
55
+ # Spyke override
56
+ #
57
+ def add_to_parent(record)
58
+ raise ArgumentError, "Expected an instance of #{klass}, got a #{record.class} instead" unless record.kind_of?(klass)
59
+ find_some << record
60
+ record.embedded_in_portal
61
+ record
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ class Relation < ::Spyke::Relation
6
+ SORT_PARAM_MATCHER = /(.*?)(!|__desc(?:end)?)?\Z/.freeze
7
+
8
+ # NOTE: We need to keep limit, offset, sort, query and portal accessors
9
+ # separate from regular params because FM Data API uses either "limit" or
10
+ # "_limit" (or "_offset", etc.) as param keys depending on the type of
11
+ # request, so we can't set the params until the last moment
12
+
13
+
14
+ attr_accessor :limit_value, :offset_value, :sort_params, :query_params,
15
+ :included_portals, :portal_params, :script_params
16
+
17
+ def initialize(*_args)
18
+ super
19
+
20
+ @limit_value = klass.default_limit
21
+
22
+ if klass.default_sort.present?
23
+ @sort_params = Array.wrap(klass.default_sort).map { |s| normalize_sort_param(s) }
24
+ end
25
+
26
+ @query_params = []
27
+
28
+ @included_portals = nil
29
+ @portal_params = {}
30
+ @script_params = {}
31
+ end
32
+
33
+ # @param options [String, Array, Hash, nil, false] sets script params to
34
+ # execute in the next get or find request
35
+ #
36
+ # @example
37
+ # # Find records and run the script named "My script"
38
+ # Person.script("My script").find_some
39
+ #
40
+ # # Find records and run the script named "My script" with param "the param"
41
+ # Person.script(["My script", "the param"]).find_some
42
+ #
43
+ # # Find records and run a prerequest, presort and after (normal) script
44
+ # Person.script(after: "Script", prerequest: "Prereq script", presort: "Presort script").find_some
45
+ #
46
+ # # Same as above, but passing parameters too
47
+ # Person.script(
48
+ # after: ["After script", "the param"],
49
+ # prerequest: ["Prereq script", "the param"],
50
+ # presort: o ["Presort script", "the param"]
51
+ # ).find_some
52
+ #
53
+ # Person.script(nil).find_some # Disable script execution
54
+ # Person.script(false).find_some # Disable script execution
55
+ #
56
+ # @return [FmRest::Spyke::Relation] a new relation with the script
57
+ # options applied
58
+ def script(options)
59
+ with_clone do |r|
60
+ if options.eql?(false) || options.eql?(nil)
61
+ r.script_params = {}
62
+ else
63
+ r.script_params = script_params.merge(FmRest::V1.convert_script_params(options))
64
+ end
65
+ end
66
+ end
67
+
68
+ # @param value_or_hash [Integer, Hash] the limit value for this layout,
69
+ # or a hash with limits for the layout's portals
70
+ # @example
71
+ # Person.limit(10) # Set layout limit
72
+ # Person.limit(children: 10) # Set portal limit
73
+ # @return [FmRest::Spyke::Relation] a new relation with the limits
74
+ # applied
75
+ def limit(value_or_hash)
76
+ with_clone do |r|
77
+ if value_or_hash.respond_to?(:each)
78
+ r.set_portal_params(value_or_hash, :limit)
79
+ else
80
+ r.limit_value = value_or_hash
81
+ end
82
+ end
83
+ end
84
+
85
+ # @param value_or_hash [Integer, Hash] the offset value for this layout,
86
+ # or a hash with offsets for the layout's portals
87
+ # @example
88
+ # Person.offset(10) # Set layout offset
89
+ # Person.offset(children: 10) # Set portal offset
90
+ # @return [FmRest::Spyke::Relation] a new relation with the offsets
91
+ # applied
92
+ def offset(value_or_hash)
93
+ with_clone do |r|
94
+ if value_or_hash.respond_to?(:each)
95
+ r.set_portal_params(value_or_hash, :offset)
96
+ else
97
+ r.offset_value = value_or_hash
98
+ end
99
+ end
100
+ end
101
+
102
+ # Allows sort params given in either hash format (using FM Data API's
103
+ # format), or as a symbol, in which case the of the attribute must match
104
+ # a known mapped attribute, optionally suffixed with `!` or `__desc` to
105
+ # signify it should use descending order.
106
+ #
107
+ # @param args [Array<Symbol, Hash>] the names of attributes to sort by with
108
+ # optional `!` or `__desc` suffix, or a hash of options as expected by
109
+ # the FM Data API
110
+ # @example
111
+ # Person.sort(:first_name, :age!)
112
+ # Person.sort(:first_name, :age__desc)
113
+ # Person.sort(:first_name, :age__descend)
114
+ # Person.sort({ fieldName: "FirstName" }, { fieldName: "Age", sortOrder: "descend" })
115
+ # @return [FmRest::Spyke::Relation] a new relation with the sort options
116
+ # applied
117
+ def sort(*args)
118
+ with_clone do |r|
119
+ r.sort_params = args.flatten.map { |s| normalize_sort_param(s) }
120
+ end
121
+ end
122
+ alias order sort
123
+
124
+ # Sets the portals to include with each record in the response.
125
+ #
126
+ # @param args [Array<Symbol, String>, true, false] the names of portals to
127
+ # include, or `false` to request no portals
128
+ # @example
129
+ # Person.portal(:relatives, :pets)
130
+ # Person.portal(false) # Disables portals
131
+ # Person.portal(true) # Enables portals (includes all)
132
+ # @return [FmRest::Spyke::Relation] a new relation with the portal
133
+ # options applied
134
+ def portal(*args)
135
+ raise ArgumentError, "Call `portal' with at least one argument" if args.empty?
136
+
137
+ with_clone do |r|
138
+ if args.length == 1 && args.first.eql?(true) || args.first.eql?(false)
139
+ r.included_portals = args.first ? nil : []
140
+ else
141
+ r.included_portals ||= []
142
+ r.included_portals += args.flatten.map { |p| normalize_portal_param(p) }
143
+ r.included_portals.uniq!
144
+ end
145
+ end
146
+ end
147
+ alias includes portal
148
+ alias portals portal
149
+
150
+ # Same as calling `portal(true)`
151
+ #
152
+ # @return (see #portal)
153
+ def with_all_portals
154
+ portal(true)
155
+ end
156
+
157
+ # Same as calling `portal(false)`
158
+ #
159
+ # @return (see #portal)
160
+ def without_portals
161
+ portal(false)
162
+ end
163
+
164
+ def query(*params)
165
+ with_clone do |r|
166
+ r.query_params += params.flatten.map { |p| normalize_query_params(p) }
167
+ end
168
+ end
169
+
170
+ def omit(params)
171
+ query(params.merge(omit: true))
172
+ end
173
+
174
+ # @return [Boolean] whether a query was set on this relation
175
+ def has_query?
176
+ query_params.present?
177
+ end
178
+
179
+ # Finds a single instance of the model by forcing limit = 1, or simply
180
+ # fetching the record by id if the primary key was set
181
+ #
182
+ # @return [FmRest::Spyke::Base]
183
+ def find_one
184
+ @find_one ||=
185
+ if primary_key_set?
186
+ without_collection_params { super }
187
+ else
188
+ klass.new_collection_from_result(limit(1).fetch).first
189
+ end
190
+ rescue ::Spyke::ConnectionError => error
191
+ fallback_or_reraise(error, default: nil)
192
+ end
193
+ alias_method :first, :find_one
194
+ alias_method :any, :find_one
195
+
196
+ # Yields each batch of records that was found by the find options.
197
+ #
198
+ # NOTE: By its nature, batch processing is subject to race conditions if
199
+ # other processes are modifying the database
200
+ #
201
+ # @param batch_size [Integer] Specifies the size of the batch.
202
+ # @return [Enumerator] if called without a block.
203
+ def find_in_batches(batch_size: 1000)
204
+ unless block_given?
205
+ return to_enum(:find_in_batches, batch_size: batch_size) do
206
+ total = limit(1).find_some.metadata.data_info.found_count
207
+ (total - 1).div(batch_size) + 1
208
+ end
209
+ end
210
+
211
+ offset = 1 # DAPI offset is 1-based
212
+
213
+ loop do
214
+ relation = offset(offset).limit(batch_size)
215
+
216
+ records = relation.find_some
217
+
218
+ yield records if records.length > 0
219
+
220
+ break if records.length < batch_size
221
+
222
+ # Save one iteration if the total is a multiple of batch_size
223
+ if found_count = records.metadata.data_info && records.metadata.data_info.found_count
224
+ break if found_count == (offset - 1) + batch_size
225
+ end
226
+
227
+ offset += batch_size
228
+ end
229
+ end
230
+
231
+ # Looping through a collection of records from the database (using the
232
+ # #all method, for example) is very inefficient since it will fetch and
233
+ # instantiate all the objects at once.
234
+ #
235
+ # In that case, batch processing methods allow you to work with the
236
+ # records in batches, thereby greatly reducing memory consumption and be
237
+ # lighter on the Data API server.
238
+ #
239
+ # The find_each method uses #find_in_batches with a batch size of 1000
240
+ # (or as specified by the :batch_size option).
241
+ #
242
+ # NOTE: By its nature, batch processing is subject to race conditions if
243
+ # other processes are modifying the database
244
+ #
245
+ # @param (see #find_in_batches)
246
+ # @example
247
+ # Person.find_each do |person|
248
+ # person.greet
249
+ # end
250
+ #
251
+ # Person.query(name: "==Mitch").find_each do |person|
252
+ # person.say_hi
253
+ # end
254
+ # @return (see #find_in_batches)
255
+ def find_each(batch_size: 1000)
256
+ unless block_given?
257
+ return to_enum(:find_each, batch_size: batch_size) do
258
+ limit(1).find_some.metadata.data_info.found_count
259
+ end
260
+ end
261
+
262
+ find_in_batches(batch_size: batch_size) do |records|
263
+ records.each { |r| yield r }
264
+ end
265
+ end
266
+
267
+ protected
268
+
269
+ def set_portal_params(params_hash, param)
270
+ # Copy portal_params so we're not modifying the same hash as the parent
271
+ # scope
272
+ self.portal_params = portal_params.dup
273
+
274
+ params_hash.each do |portal_name, value|
275
+ # TODO: Use a hash like { portal_name: { param: value } } instead so
276
+ # we can intelligently avoid including portal params for excluded
277
+ # portals
278
+ key = "#{param}.#{normalize_portal_param(portal_name)}"
279
+
280
+ # Delete key if value is falsy
281
+ if !value && portal_params.has_key?(key)
282
+ portal_params.delete(key)
283
+ else
284
+ self.portal_params[key] = value
285
+ end
286
+ end
287
+ end
288
+
289
+ private
290
+
291
+ def normalize_sort_param(param)
292
+ if param.kind_of?(Symbol) || param.kind_of?(String)
293
+ _, attr, descend = param.to_s.match(SORT_PARAM_MATCHER).to_a
294
+
295
+ unless field_name = klass.mapped_attributes[attr]
296
+ raise ArgumentError, "Unknown attribute `#{attr}' given to sort as #{param.inspect}. If you want to use a custom sort pass a hash in the Data API format"
297
+ end
298
+
299
+ hash = { fieldName: field_name }
300
+ hash[:sortOrder] = "descend" if descend
301
+ return hash
302
+ end
303
+
304
+ # TODO: Sanitize sort hash param for FM Data API conformity?
305
+ param
306
+ end
307
+
308
+ def normalize_portal_param(param)
309
+ if param.kind_of?(Symbol)
310
+ portal_key, = klass.portal_options.find { |_, opts| opts[:name].to_s == param.to_s }
311
+
312
+ unless portal_key
313
+ raise ArgumentError, "Unknown portal #{param.inspect}. If you want to include a portal not defined in the model pass it as a string instead"
314
+ end
315
+
316
+ return portal_key
317
+ end
318
+
319
+ param
320
+ end
321
+
322
+ def normalize_query_params(params)
323
+ params.each_with_object({}) do |(k, v), normalized|
324
+ if k == :omit || k == "omit"
325
+ # FM Data API wants omit values as strings, e.g. "true" or "false"
326
+ # rather than true/false
327
+ normalized["omit"] = v.to_s
328
+ next
329
+ end
330
+
331
+ # TODO: Raise ArgumentError if an attribute given as symbol isn't defiend
332
+ if k.kind_of?(Symbol) && klass.mapped_attributes.has_key?(k)
333
+ normalized[klass.mapped_attributes[k].to_s] = v
334
+ else
335
+ normalized[k.to_s] = v
336
+ end
337
+ end
338
+ end
339
+
340
+ def primary_key_set?
341
+ params[klass.primary_key].present?
342
+ end
343
+
344
+ def without_collection_params
345
+ orig_values = limit_value, offset_value, sort_params, query_params
346
+ self.limit_value = self.offset_value = self.sort_params = self.query_params = nil
347
+ yield
348
+ ensure
349
+ self.limit_value, self.offset_value, self.sort_params, self.query_params = orig_values
350
+ end
351
+
352
+ def with_clone
353
+ clone.tap do |relation|
354
+ yield relation
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end