fmrest-spyke 0.13.0

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