fmrest 0.6.0 → 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +175 -40
- data/lib/fmrest/spyke.rb +1 -1
- data/lib/fmrest/spyke/base.rb +2 -0
- data/lib/fmrest/spyke/model.rb +2 -0
- data/lib/fmrest/spyke/model/associations.rb +2 -2
- data/lib/fmrest/spyke/model/connection.rb +30 -11
- data/lib/fmrest/spyke/model/global_fields.rb +40 -0
- data/lib/fmrest/spyke/model/orm.rb +2 -1
- data/lib/fmrest/spyke/model/serialization.rb +16 -5
- data/lib/fmrest/spyke/relation.rb +73 -0
- data/lib/fmrest/spyke/{json_parser.rb → spyke_formatter.rb} +50 -17
- data/lib/fmrest/string_date.rb +220 -0
- data/lib/fmrest/v1.rb +6 -0
- data/lib/fmrest/v1/connection.rb +8 -7
- data/lib/fmrest/v1/dates.rb +81 -0
- data/lib/fmrest/v1/token_session.rb +2 -0
- data/lib/fmrest/v1/type_coercer.rb +192 -0
- data/lib/fmrest/v1/utils.rb +1 -0
- data/lib/fmrest/version.rb +1 -1
- metadata +7 -4
- data/CODE_OF_CONDUCT.md +0 -74
@@ -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,
|
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"
|
8
|
-
FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S"
|
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
|
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
|
-
|
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 [
|
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:
|
82
|
-
|
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 [
|
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
|