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.
- 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
|