fmrest 0.6.0 → 0.10.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,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, 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.fetch(:timezone, nil)
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,12 +1,24 @@
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
- class JsonParser < ::Faraday::Response::Middleware
21
+ class SpykeFormatter < ::Faraday::Response::Middleware
10
22
  SINGLE_RECORD_RE = %r(/records/\d+\z).freeze
11
23
  MULTIPLE_RECORDS_RE = %r(/records\z).freeze
12
24
  CONTAINER_RE = %r(/records/\d+/containers/[^/]+/\d+\z).freeze
@@ -24,7 +36,9 @@ module FmRest
24
36
 
25
37
  # @param env [Faraday::Env]
26
38
  def on_complete(env)
27
- json = parse_json(env.body)
39
+ return unless env.body.is_a?(Hash)
40
+
41
+ json = env.body
28
42
 
29
43
  case
30
44
  when single_record_request?(env)
@@ -75,36 +89,61 @@ module FmRest
75
89
 
76
90
  # @param json [Hash]
77
91
  # @param include_errors [Boolean]
78
- # @return [Hash] the skeleton structure for a Spyke-formatted response
92
+ # @return [FmRest::Spyke::Metadata] the skeleton structure for a
93
+ # Spyke-formatted response
79
94
  def build_base_hash(json, include_errors = false)
80
95
  {
81
- metadata: { messages: json[:messages] }.merge(script: prepare_script_results(json).presence),
82
- 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) : {}
83
102
  }
84
103
  end
85
104
 
86
105
  # @param json [Hash]
87
- # @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
88
116
  def prepare_script_results(json)
89
117
  results = {}
90
118
 
91
119
  [:prerequest, :presort].each do |s|
92
120
  if json[:response][:"scriptError.#{s}"]
93
- results[s] = {
121
+ results[s] = OpenStruct.new(
94
122
  result: json[:response][:"scriptResult.#{s}"],
95
123
  error: json[:response][:"scriptError.#{s}"]
96
- }
124
+ ).freeze
97
125
  end
98
126
  end
99
127
 
100
128
  if json[:response][:scriptError]
101
- results[:after] = {
129
+ results[:after] = OpenStruct.new(
102
130
  result: json[:response][:scriptResult],
103
131
  error: json[:response][:scriptError]
104
- }
132
+ ).freeze
105
133
  end
106
134
 
107
- 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
108
147
  end
109
148
 
110
149
  # @param json [Hash]
@@ -229,12 +268,6 @@ module FmRest
229
268
  def execute_script_request?(env)
230
269
  env.method == :get && env.url.path.match(SCRIPT_REQUEST_RE)
231
270
  end
232
-
233
- # @param source [String] a JSON string
234
- # @return [Hash] the parsed JSON
235
- def parse_json(source)
236
- JSON.parse(source, symbolize_names: true)
237
- end
238
271
  end
239
272
  end
240
273
  end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module FmRest
6
+ # Gotchas:
7
+ #
8
+ # 1.
9
+ #
10
+ # Date === <StringDate instance> # => false
11
+ #
12
+ # The above can affect case conditions, as trying to match a StringDate
13
+ # with:
14
+ #
15
+ # case obj
16
+ # when Date
17
+ # ...
18
+ #
19
+ # ...will not work.
20
+ #
21
+ # Instead one must specify the FmRest::StringDate class:
22
+ #
23
+ # case obj
24
+ # when Date, FmRest::StringDate
25
+ # ...
26
+ #
27
+ # 2.
28
+ #
29
+ # StringDate#eql? only matches other strings, not dates.
30
+ #
31
+ # This could affect hash indexing when a StringDate is used as a key.
32
+ #
33
+ # TODO: Verify the above
34
+ #
35
+ # 3.
36
+ #
37
+ # StringDate#succ and StringDate#next return a String, despite Date#succ
38
+ # and Date#next also existing.
39
+ #
40
+ # Workaround: Use StringDate#next_day or strdate + 1
41
+ #
42
+ # 4.
43
+ #
44
+ # StringDate#to_s returns the original string, not the Date string
45
+ # representation.
46
+ #
47
+ # Workaround: Use strdate.to_date.to_s
48
+ #
49
+ # 5.
50
+ #
51
+ # StringDate#hash returns the hash for the string (important when using
52
+ # a StringDate as a hash key)
53
+ #
54
+ # 6.
55
+ #
56
+ # StringDate#as_json returns the string
57
+ #
58
+ # Workaround: Use strdate.to_date.as_json
59
+ #
60
+ # 7.
61
+ #
62
+ # Equality with Date is not reciprocal:
63
+ #
64
+ # str_date == date #=> true
65
+ # date == str_date #=> false
66
+ #
67
+ # NOTE: Potential workaround: Inherit StringDate from Date instead of String
68
+ #
69
+ # 8.
70
+ #
71
+ # Calling string transforming methods (e.g. .upcase) returns a StringDate
72
+ # instead of a String.
73
+ #
74
+ # NOTE: Potential workaround: Inherit StringDate from Date instead of String
75
+ #
76
+ class StringDate < String
77
+ DELEGATE_CLASS = ::Date
78
+
79
+ class InvalidDate < ArgumentError; end
80
+
81
+ class << self
82
+ def strptime(str, date_format, *_)
83
+ begin
84
+ date = self::DELEGATE_CLASS.strptime(str, date_format)
85
+ rescue ArgumentError
86
+ raise InvalidDate
87
+ end
88
+
89
+ new(str, date)
90
+ end
91
+ end
92
+
93
+ def initialize(str, date, **str_args)
94
+ raise ArgumentError, "str must be of class String" unless str.is_a?(String)
95
+ raise ArgumentError, "date must be of class #{self.class::DELEGATE_CLASS.name}" unless date.is_a?(self.class::DELEGATE_CLASS)
96
+
97
+ super(str, **str_args)
98
+
99
+ @delegate = date
100
+
101
+ freeze
102
+ end
103
+
104
+ def is_a?(klass)
105
+ klass == ::Date || super
106
+ end
107
+ alias_method :kind_of?, :is_a?
108
+
109
+ def to_date
110
+ @delegate
111
+ end
112
+
113
+ def to_datetime
114
+ @delegate.to_datetime
115
+ end
116
+
117
+ def to_time
118
+ @delegate.to_time
119
+ end
120
+
121
+ # ActiveSupport method
122
+ def in_time_zone(*_)
123
+ @delegate.in_time_zone(*_)
124
+ end
125
+
126
+ def inspect
127
+ "#<#{self.class.name} #{@delegate.inspect} - #{super}>"
128
+ end
129
+
130
+ def <=>(oth)
131
+ return @delegate <=> oth if oth.is_a?(::Date) || oth.is_a?(Numeric)
132
+ super
133
+ end
134
+
135
+ def +(val)
136
+ return @delegate + val if val.kind_of?(Numeric)
137
+ super
138
+ end
139
+
140
+ def <<(val)
141
+ return @delegate << val if val.kind_of?(Numeric)
142
+ super
143
+ end
144
+
145
+ def ==(oth)
146
+ return @delegate == oth if oth.kind_of?(::Date) || oth.kind_of?(Numeric)
147
+ super
148
+ end
149
+ alias_method :===, :==
150
+
151
+ def upto(oth, &blk)
152
+ return @delegate.upto(oth, &blk) if oth.kind_of?(::Date) || oth.kind_of?(Numeric)
153
+ super
154
+ end
155
+
156
+ def between?(a, b)
157
+ return @delegate.between?(a, b) if [a, b].any? {|o| o.is_a?(::Date) || o.is_a?(Numeric) }
158
+ super
159
+ end
160
+
161
+ private
162
+
163
+ def respond_to_missing?(name, include_private = false)
164
+ @delegate.respond_to?(name, include_private)
165
+ end
166
+
167
+ def method_missing(method, *args, &block)
168
+ @delegate.send(method, *args, &block)
169
+ end
170
+ end
171
+
172
+ class StringDateTime < StringDate
173
+ DELEGATE_CLASS = ::DateTime
174
+
175
+ def is_a?(klass)
176
+ klass == ::DateTime || super
177
+ end
178
+ alias_method :kind_of?, :is_a?
179
+
180
+ def to_date
181
+ @delegate.to_date
182
+ end
183
+
184
+ def to_datetime
185
+ @delegate
186
+ end
187
+ end
188
+
189
+ module StringDateAwareness
190
+ def _parse(v, *_)
191
+ if v.is_a?(StringDateTime)
192
+ return { year: v.year, mon: v.month, mday: v.mday, hour: v.hour, min: v.min, sec: v.sec, sec_fraction: v.sec_fraction, offset: v.offset }
193
+ end
194
+ if v.is_a?(StringDate)
195
+ return { year: v.year, mon: v.month, mday: v.mday }
196
+ end
197
+ super
198
+ end
199
+
200
+ def parse(v, *_)
201
+ if v.is_a?(StringDate)
202
+ return self == ::DateTime ? v.to_datetime : v.to_date
203
+ end
204
+ super
205
+ end
206
+
207
+ # Overriding case equality method so that it returns true for
208
+ # `FmRest::StringDate` instances
209
+ #
210
+ # Calls superclass method
211
+ #
212
+ def ===(other)
213
+ super || other.is_a?(StringDate)
214
+ end
215
+
216
+ def self.enable(classes: [Date, DateTime])
217
+ classes.each { |klass| klass.singleton_class.prepend(self) }
218
+ end
219
+ end
220
+ end