fmrest 0.6.0 → 0.10.0

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