fmrest 0.7.1 → 0.11.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.
@@ -21,6 +21,8 @@ module FmRest
21
21
  class APIError::NoMatchingRecordsError < APIError::ParameterError; end
22
22
  class APIError::ValidationError < APIError; end # error codes 500..599
23
23
  class APIError::SystemError < APIError; end # error codes 800..899
24
+ class APIError::InvalidToken < APIError; end # error code 952
25
+ class APIError::MaximumDataAPICallsExceeded < APIError; end # error code 953
24
26
  class APIError::ScriptError < APIError; end # error codes 1200..1299
25
27
  class APIError::ODBCError < APIError; end # error codes 1400..1499
26
28
 
@@ -8,6 +8,8 @@ module FmRest
8
8
 
9
9
  class << self
10
10
  def Base(config = nil)
11
+ warn "[DEPRECATION] Inheriting from `FmRest::Spyke::Base(config)` is deprecated and will be removed, inherit from `FmRest::Spyke::Base` (without arguments) and use `fmrest_config=` instead"
12
+
11
13
  if config
12
14
  return Class.new(::FmRest::Spyke::Base) do
13
15
  self.fmrest_config = config
@@ -7,6 +7,7 @@ require "fmrest/spyke/model/serialization"
7
7
  require "fmrest/spyke/model/associations"
8
8
  require "fmrest/spyke/model/orm"
9
9
  require "fmrest/spyke/model/container_fields"
10
+ require "fmrest/spyke/model/global_fields"
10
11
  require "fmrest/spyke/model/http"
11
12
  require "fmrest/spyke/model/auth"
12
13
 
@@ -22,6 +23,7 @@ module FmRest
22
23
  include Associations
23
24
  include Orm
24
25
  include ContainerFields
26
+ include GlobalFields
25
27
  include Http
26
28
  include Auth
27
29
 
@@ -28,6 +28,14 @@ module FmRest
28
28
  rescue FmRest::V1::TokenSession::NoSessionTokenSet
29
29
  false
30
30
  end
31
+
32
+ def request_auth_token
33
+ FmRest::V1.request_auth_token(FmRest::V1.auth_connection(fmrest_config))
34
+ end
35
+
36
+ def request_auth_token!
37
+ FmRest::V1.request_auth_token!(FmRest::V1.auth_connection(fmrest_config))
38
+ end
31
39
  end
32
40
  end
33
41
  end
@@ -4,19 +4,70 @@ module FmRest
4
4
  module Spyke
5
5
  module Model
6
6
  module Connection
7
- extend ::ActiveSupport::Concern
7
+ extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- class_attribute :fmrest_config, instance_accessor: false, instance_predicate: false
11
-
12
10
  class_attribute :faraday_block, instance_accessor: false, instance_predicate: false
13
11
  class << self; private :faraday_block, :faraday_block=; end
14
12
 
15
- # FM Data API expects PATCH for updates (Spyke's default was PUT)
13
+ # FM Data API expects PATCH for updates (Spyke's default is PUT)
16
14
  self.callback_methods = { create: :post, update: :patch }.freeze
17
15
  end
18
16
 
19
17
  class_methods do
18
+ def fmrest_config
19
+ if fmrest_config_overlay
20
+ return FmRest.default_connection_settings.merge(fmrest_config_overlay, skip_validation: true)
21
+ end
22
+
23
+ FmRest.default_connection_settings
24
+ end
25
+
26
+ # Behaves similar to ActiveSupport's class_attribute, redefining the
27
+ # reader method so it can be inherited and overwritten in subclasses
28
+ #
29
+ def fmrest_config=(settings)
30
+ settings = ConnectionSettings.new(settings, skip_validation: true)
31
+
32
+ redefine_singleton_method(:fmrest_config) do
33
+ overlay = fmrest_config_overlay
34
+ return settings.merge(overlay, skip_validation: true) if overlay
35
+ settings
36
+ end
37
+ end
38
+
39
+ # Allows overwriting some connection settings in a thread-local
40
+ # manner. Useful in the use case where you want to connect to the
41
+ # same database using different accounts (e.g. credentials provided
42
+ # by users in a web app context)
43
+ #
44
+ def fmrest_config_overlay=(settings)
45
+ Thread.current[fmrest_config_overlay_key] = settings
46
+ end
47
+
48
+ def fmrest_config_overlay
49
+ Thread.current[fmrest_config_overlay_key] || begin
50
+ superclass.fmrest_config_overlay
51
+ rescue NoMethodError
52
+ nil
53
+ end
54
+ end
55
+
56
+ def clear_fmrest_config_overlay
57
+ Thread.current[fmrest_config_overlay_key] = nil
58
+ end
59
+
60
+ def with_overlay(settings, &block)
61
+ Fiber.new do
62
+ begin
63
+ self.fmrest_config_overlay = settings
64
+ yield
65
+ ensure
66
+ self.clear_fmrest_config_overlay
67
+ end
68
+ end.resume
69
+ end
70
+
20
71
  def connection
21
72
  super || fmrest_connection
22
73
  end
@@ -38,26 +89,45 @@ module FmRest
38
89
  private
39
90
 
40
91
  def fmrest_connection
41
- @fmrest_connection ||=
42
- begin
43
- config = fmrest_config || FmRest.default_connection_settings
92
+ memoize = false
93
+
94
+ # Don't memoize the connection if there's an overlay, since
95
+ # overlays are thread-local and so should be the connection
96
+ unless fmrest_config_overlay
97
+ return @fmrest_connection if @fmrest_connection
98
+ memoize = true
99
+ end
44
100
 
45
- FmRest::V1.build_connection(config) do |conn|
46
- faraday_block.call(conn) if faraday_block
101
+ config = ConnectionSettings.wrap(fmrest_config)
47
102
 
48
- # Pass the class to SpykeFormatter's initializer so it can have
49
- # access to extra context defined in the model, e.g. a portal
50
- # where name of the portal and the attributes prefix don't match
51
- # and need to be specified as options to `portal`
52
- conn.use FmRest::Spyke::SpykeFormatter, self
103
+ connection =
104
+ FmRest::V1.build_connection(config) do |conn|
105
+ faraday_block.call(conn) if faraday_block
53
106
 
54
- conn.use FmRest::V1::TypeCoercer, config
107
+ # Pass the class to SpykeFormatter's initializer so it can have
108
+ # access to extra context defined in the model, e.g. a portal
109
+ # where name of the portal and the attributes prefix don't match
110
+ # and need to be specified as options to `portal`
111
+ conn.use FmRest::Spyke::SpykeFormatter, self
55
112
 
56
- # FmRest::Spyke::JsonParse expects symbol keys
57
- conn.response :json, parser_options: { symbolize_names: true }
58
- end
113
+ conn.use FmRest::V1::TypeCoercer, config
114
+
115
+ # FmRest::Spyke::JsonParse expects symbol keys
116
+ conn.response :json, parser_options: { symbolize_names: true }
59
117
  end
118
+
119
+ @fmrest_connection = connection if memoize
120
+
121
+ connection
60
122
  end
123
+
124
+ def fmrest_config_overlay_key
125
+ :"#{object_id}.fmrest_config_overlay"
126
+ end
127
+ end
128
+
129
+ def fmrest_config
130
+ self.class.fmrest_config
61
131
  end
62
132
  end
63
133
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module GlobalFields
7
+ extend ::ActiveSupport::Concern
8
+
9
+ FULLY_QUALIFIED_FIELD_NAME_MATCHER = /\A[^:]+::[^:]+\Z/.freeze
10
+
11
+ class_methods do
12
+ def set_globals(values_hash)
13
+ connection.patch(FmRest::V1.globals_path, {
14
+ globalFields: normalize_globals_hash(values_hash)
15
+ })
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_globals_hash(hash)
21
+ hash.each_with_object({}) do |(k, v), normalized|
22
+ if v.kind_of?(Hash)
23
+ v.each do |k2, v2|
24
+ normalized["#{k}::#{k2}"] = v2
25
+ end
26
+ next
27
+ end
28
+
29
+ unless FULLY_QUALIFIED_FIELD_NAME_MATCHER === k.to_s
30
+ raise ArgumentError, "global fields must be given in fully qualified format (table name::field name)"
31
+ end
32
+
33
+ normalized[k] = v
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -24,7 +24,8 @@ module FmRest
24
24
  # Methods delegated to FmRest::Spyke::Relation
25
25
  delegate :limit, :offset, :sort, :order, :query, :omit, :portal,
26
26
  :portals, :includes, :with_all_portals, :without_portals,
27
- :script, :find_one, :find_some, to: :all
27
+ :script, :find_one, :first, :any, :find_some,
28
+ :find_in_batches, :find_each, to: :all
28
29
 
29
30
  def all
30
31
  # Use FmRest's Relation instead of Spyke's vanilla one
@@ -4,8 +4,8 @@ module FmRest
4
4
  module Spyke
5
5
  module Model
6
6
  module Serialization
7
- FM_DATE_FORMAT = "%m/%d/%Y".freeze
8
- FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S".freeze
7
+ FM_DATE_FORMAT = "%m/%d/%Y"
8
+ FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S"
9
9
 
10
10
  # Override Spyke's to_params to return FM Data API's expected JSON
11
11
  # format, and including only modified fields
@@ -63,9 +63,9 @@ module FmRest
63
63
  def serialize_values!(params)
64
64
  params.transform_values! do |value|
65
65
  case value
66
- when DateTime, Time
67
- value.strftime(FM_DATETIME_FORMAT)
68
- when Date
66
+ when DateTime, Time, FmRest::StringDateTime
67
+ convert_datetime_timezone(value.to_datetime).strftime(FM_DATETIME_FORMAT)
68
+ when Date, FmRest::StringDate
69
69
  value.strftime(FM_DATE_FORMAT)
70
70
  else
71
71
  value
@@ -74,6 +74,17 @@ module FmRest
74
74
 
75
75
  params
76
76
  end
77
+
78
+ def convert_datetime_timezone(dt)
79
+ case fmrest_config.timezone
80
+ when :utc, "utc"
81
+ dt.new_offset(0)
82
+ when :local, "local"
83
+ dt.new_offset(FmRest::V1.local_offset_for_datetime(dt))
84
+ when nil
85
+ dt
86
+ end
87
+ end
77
88
  end
78
89
  end
79
90
  end
@@ -190,6 +190,79 @@ module FmRest
190
190
  rescue ::Spyke::ConnectionError => error
191
191
  fallback_or_reraise(error, default: nil)
192
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
193
266
 
194
267
  protected
195
268
 
@@ -1,9 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "ostruct"
4
5
 
5
6
  module FmRest
6
7
  module Spyke
8
+ # Metadata class to be passed to Spyke::Collection#metadata
9
+ class Metadata < Struct.new(:messages, :script, :data_info)
10
+ alias_method :scripts, :script
11
+ end
12
+
13
+ class DataInfo < OpenStruct
14
+ def total_record_count; totalRecordCount; end
15
+ def found_count; foundCount; end
16
+ def returned_count; returnedCount; end
17
+ end
18
+
7
19
  # Response Faraday middleware for converting FM API's response JSON into
8
20
  # Spyke's expected format
9
21
  class SpykeFormatter < ::Faraday::Response::Middleware
@@ -77,36 +89,61 @@ module FmRest
77
89
 
78
90
  # @param json [Hash]
79
91
  # @param include_errors [Boolean]
80
- # @return [Hash] the skeleton structure for a Spyke-formatted response
92
+ # @return [FmRest::Spyke::Metadata] the skeleton structure for a
93
+ # Spyke-formatted response
81
94
  def build_base_hash(json, include_errors = false)
82
95
  {
83
- metadata: { messages: json[:messages] }.merge(script: prepare_script_results(json).presence),
84
- errors: include_errors ? prepare_errors(json) : {}
96
+ metadata: Metadata.new(
97
+ prepare_messages(json),
98
+ prepare_script_results(json),
99
+ prepare_data_info(json)
100
+ ).freeze,
101
+ errors: include_errors ? prepare_errors(json) : {}
85
102
  }
86
103
  end
87
104
 
88
105
  # @param json [Hash]
89
- # @return [Hash] the script(s) execution results for Spyke metadata format
106
+ # @return [Array<OpenStruct>] the skeleton structure for a
107
+ # Spyke-formatted response
108
+ def prepare_messages(json)
109
+ return [] unless json[:messages]
110
+ json[:messages].map { |m| OpenStruct.new(m).freeze }.freeze
111
+ end
112
+
113
+ # @param json [Hash]
114
+ # @return [OpenStruct] the script(s) execution results for Spyke metadata
115
+ # format
90
116
  def prepare_script_results(json)
91
117
  results = {}
92
118
 
93
119
  [:prerequest, :presort].each do |s|
94
120
  if json[:response][:"scriptError.#{s}"]
95
- results[s] = {
121
+ results[s] = OpenStruct.new(
96
122
  result: json[:response][:"scriptResult.#{s}"],
97
123
  error: json[:response][:"scriptError.#{s}"]
98
- }
124
+ ).freeze
99
125
  end
100
126
  end
101
127
 
102
128
  if json[:response][:scriptError]
103
- results[:after] = {
129
+ results[:after] = OpenStruct.new(
104
130
  result: json[:response][:scriptResult],
105
131
  error: json[:response][:scriptError]
106
- }
132
+ ).freeze
107
133
  end
108
134
 
109
- results
135
+ results.present? ? OpenStruct.new(results).freeze : nil
136
+ end
137
+
138
+ # @param json [Hash]
139
+ # @return [OpenStruct] the script(s) execution results for
140
+ # Spyke metadata format
141
+ def prepare_data_info(json)
142
+ data_info = json[:response] && json[:response][:dataInfo]
143
+
144
+ return nil unless data_info.present?
145
+
146
+ DataInfo.new(data_info).freeze
110
147
  end
111
148
 
112
149
  # @param json [Hash]