fmrest 0.7.1 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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]