fmrest 0.7.0 → 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab46f70bffcfea32ef2bfd5021f03fa654bb43134e22b8e22a20d9955afe9caf
4
- data.tar.gz: 32ce4378ff291d2c41d9b255b032d4562d7544a477765748c87c49a655449405
3
+ metadata.gz: ccf62593af664237c1cd0c67928c869ae74cdcde4b2e3c9bd82c20fa0f6b0354
4
+ data.tar.gz: 9f6bd645aed2551d278b0b31e194dc97726e66396a76148ee7dae3bca5c67a93
5
5
  SHA512:
6
- metadata.gz: a68af2d8e53631b1d5c4d9e5e0849d224ff3918d1138d437463e30318ae81c36ca968d226f2f3631dbaa0d21b6287c1899e3b322a716729ad0d8b77aff80fd8d
7
- data.tar.gz: ace50f913960e6b2edd190c8c1cbb75a0ebf15c0c3f0e8343720155f1c53b78bb9fd942be0a5e9bf2a2bc1ff729277f86360ffa4bba397ba48abe86c07db02ad
6
+ metadata.gz: 421a3be4119f862e31787b6027c7824e42641d85d6dbdcb6d5df2b849ef3e93556a03060a74e093a11fed962cdfbe61be19833c5d2195f6b4dad121ef759aeb6
7
+ data.tar.gz: 7bd1359945a7da96afdfd05c8948148bc6c8740351eb7c4dd1a31ba501fc82f5ad79dd68a166b2fee3022c636fddd187f0b27b97fc2d645922afddb813f98787
@@ -1,5 +1,36 @@
1
1
  ## Changelog
2
2
 
3
+ ### 0.10.1
4
+
5
+ * Fix `URI.escape` obsolete warning messages in Ruby 2.7 by replacing it with
6
+ `URI.encode_www_form_component`
7
+ ([PR#40](https://github.com/beezwax/fmrest-ruby/pull/40))
8
+
9
+ ### 0.10.0
10
+
11
+ * Added `FmRest::StringDateAwareness` module to correct some issues when using
12
+ `FmRest::StringDate`
13
+ * Added basic timezones support
14
+ * Deprecated `class < FmRest::Spyke::Base(config_hash)` syntax in favor of
15
+ using `self.fmrest_config=`
16
+
17
+ ### 0.9.0
18
+
19
+ * Added `FmRest::Spyke::Base.set_globals`
20
+
21
+ ### 0.8.0
22
+
23
+ * Improved metadata when using `FmRest::Spyke::Model`. Metadata now uses
24
+ Struct/OpenStruct, so properties are accessible through `.property`, as well
25
+ as `[:property]`
26
+ * Added batch-finders `.find_in_batches` and `.find_each` for
27
+ * `FmRest::Spyke::Base`
28
+
29
+ ### 0.7.1
30
+
31
+ * Made sure `Model.find_one` and `Model.find_some` work without needing to call
32
+ `Model.all` in between
33
+
3
34
  ### 0.7.0
4
35
 
5
36
  * Added date coercion feature
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <a href="https://rubygems.org/gems/fmrest"><img src="https://badge.fury.io/rb/fmrest.svg?style=flat" alt="Gem Version"></a>
4
4
 
5
5
  A Ruby client for
6
- [FileMaker 18's Data API](https://fmhelp.filemaker.com/docs/18/en/dataapi/)
6
+ [FileMaker 18 and 19's Data API](https://help.claris.com/en/data-api-guide)
7
7
  using
8
8
  [Faraday](https://github.com/lostisland/faraday) and with optional
9
9
  [Spyke](https://github.com/balvig/spyke) support (ActiveRecord-ish models).
@@ -120,6 +120,7 @@ Option | Description | Format
120
120
  `:date_format` | Date parsing format | String (FM date format) | `"MM/dd/yyyy"`
121
121
  `:timestamp_format` | Timestmap parsing format | String (FM date format) | `"MM/dd/yyyy HH:mm:ss"`
122
122
  `:time_format` | Time parsing format | String (FM date format) | `"HH:mm:ss"`
123
+ `:timezone` | The timezone for the FM server | `:local` \| `:utc` \| `nil` | `nil`
123
124
 
124
125
  ### Default connection settings
125
126
 
@@ -195,7 +196,7 @@ FmRest.token_store = FmRest::TokenStore::Redis.new(redis: Redis.new, prefix: "my
195
196
  FmRest.token_store = FmRest::TokenStore::Redis.new(prefix: "my-fancy-prefix:", host: "10.0.1.1", port: 6380, db: 15)
196
197
  ```
197
198
 
198
- **NOTE:** redis-rb is not included as a gem dependency of fmrest-ruby, so you'll
199
+ NOTE: redis-rb is not included as a gem dependency of fmrest-ruby, so you'll
199
200
  have to add it to your Gemfile.
200
201
 
201
202
  ### Moneta
@@ -232,7 +233,7 @@ FmRest.token_store = FmRest::TokenStore::Moneta.new(
232
233
  )
233
234
  ```
234
235
 
235
- **NOTE:** the moneta gem is not included as a dependency of fmrest-ruby, so
236
+ NOTE: the moneta gem is not included as a dependency of fmrest-ruby, so
236
237
  you'll have to add it to your Gemfile.
237
238
 
238
239
 
@@ -246,14 +247,14 @@ to automatically "coerce" them into Ruby date objects.
246
247
  The connection option `:coerce_dates` controls this feature. Possible values
247
248
  are:
248
249
 
249
- * `:full`: whenever a string matches the given date/timestamp/time format,
250
+ * `:full` - whenever a string matches the given date/timestamp/time format,
250
251
  convert them to `Date` or `DateTime` objects as appropriate
251
- * `:hybrid` or `true`: similar as above, but instead of converting to regular
252
+ * `:hybrid` or `true` - similar as above, but instead of converting to regular
252
253
  `Date`/`DateTime` it converts strings to `FmRest::StringDate` and
253
254
  `FmRest::StringDateTime`, "hybrid" classes provided by fmrest-ruby that
254
255
  retain the functionality of `String` while also providing most the
255
256
  functionality of `Date`/`DateTime` (more on this below)
256
- * `false`: disable date coercion entirely (default), leave original string
257
+ * `false` - disable date coercion entirely (default), leave original string
257
258
  values untouched
258
259
 
259
260
  Enabling date coercion works with both basic fmrest-ruby connections and Spyke
@@ -270,9 +271,9 @@ a DSL in model classes).
270
271
  ### Hybrid string/date objects
271
272
 
272
273
  `FmRest::StringDate` and `FmRest::StringDateTime` are special classes that
273
- inherit from `String`, but internally parse and store a `Date`/`DateTime`
274
- (respectively), and delegate any methods not provided by `String` to those
275
- objects. In other words, they quack like a duck *and* bark like a dog.
274
+ inherit from `String`, but internally parse and store a `Date` or `DateTime`,
275
+ and delegate any methods not provided by `String` to those objects. In other
276
+ words, they quack like a duck *and* bark like a dog.
276
277
 
277
278
  You can use these when you want fmrest-ruby to provide you with date objects,
278
279
  but you don't want to worry about date coercion of false positives (i.e. a
@@ -280,7 +281,29 @@ string field that gets converted to `Date` because it just so matched the given
280
281
  date format).
281
282
 
282
283
  Be warned however that these classes come with a fair share of known gotchas
283
- (see GitHub wiki for more info).
284
+ (see GitHub wiki for more info). Some of those gothas can be removed by calling
285
+
286
+ ```ruby
287
+ FmRest::StringDateAwareness.enable
288
+ ```
289
+
290
+ Which will extend the core `Date` and `DateTime` classes to be aware of
291
+ `FmRest::StringDate`, especially when calling `Date.===`, `Date.parse` or
292
+ `Date._parse`.
293
+
294
+ If you're working with ActiveRecord models this will also make them accept
295
+ `FmRest::StringDate` values for date fields.
296
+
297
+ ### Timezones
298
+
299
+ fmrest-ruby has basic timezone support. You can set the `:timezone` option in
300
+ your connection settings to one of the following values:
301
+
302
+ * `:local` - dates will be converted to your system local time offset (as
303
+ defined by `ENV["TZ"]`), or the timezone set by `Time.zone` if you're using
304
+ ActiveSupport
305
+ * `:utc` - dates will be converted to UTC offset
306
+ * `nil` - (default) ignore timezones altogether
284
307
 
285
308
 
286
309
  ## Spyke support (ActiveRecord-like ORM)
@@ -322,17 +345,6 @@ class Honeybee < FmRest::Spyke::Base
322
345
  end
323
346
  ```
324
347
 
325
- In this case you can pass the [`fmrest_config`](#modelfmrest_config) hash as an
326
- argument to `Base()`:
327
-
328
- ```ruby
329
- class Honeybee < FmRest::Spyke::Base(host: "...", database: "...", username: "...", password: "...")
330
- end
331
-
332
- Honeybee.fmrest_config
333
- # => { host: "...", database: "...", username: "...", password: "..." }
334
- ```
335
-
336
348
  All of Spyke's basic ORM operations work:
337
349
 
338
350
  ```ruby
@@ -555,7 +567,7 @@ Honeybee.limit(10)
555
567
  ```
556
568
 
557
569
  NOTE: You can also set a default limit value for a model class, see
558
- [Other notes on querying](#other-notes-on-querying).
570
+ [other notes on querying](#other-notes-on-querying).
559
571
 
560
572
  You can also use `.limit` to set limits on portals:
561
573
 
@@ -727,15 +739,15 @@ the scope object:
727
739
  Honeybee.limit(10).sort(:name).find_some # => [<Honeybee...>, ...]
728
740
  ```
729
741
 
730
- If you want just a single result you can use `.find_one` instead (this will
742
+ If you want just a single result you can use `.first` instead (this will
731
743
  force `.limit(1)`):
732
744
 
733
745
  ```ruby
734
- Honeybee.query(name: "Hutch").find_one # => <Honeybee...>
746
+ Honeybee.query(name: "Hutch").first # => <Honeybee...>
735
747
  ```
736
748
 
737
749
  If you know the id of the record you should use `.find(id)` instead of
738
- `.query(id: id).find_one` (so that the sent request is
750
+ `.query(id: id).first` (so that the sent request is
739
751
  `GET ../:layout/records/:id` instead of `POST ../:layout/_find`).
740
752
 
741
753
  ```ruby
@@ -746,6 +758,52 @@ Note also that if you use `.find(id)` your `.query()` parameters (as well as
746
758
  limit, offset and sort parameters) will be discarded as they're not supported
747
759
  by the single record endpoint.
748
760
 
761
+
762
+ ### Finding records in batches
763
+
764
+ Sometimes you want to iterate over a very large number of records to do some
765
+ processing, but requesting them all at once would result in one huge request to
766
+ the Data API, and loading too many records in memory all at once.
767
+
768
+ To mitigate this problem you can use `.find_in_batches` and `.find_each`. If
769
+ you've used ActiveRecord you're probably familiar with how they operate:
770
+
771
+ ```ruby
772
+ # Find records in batches of 100 each
773
+ Honeybee.query(hive: "Queensville").find_in_batches(batch_size: 100) do |batch|
774
+ dispatch_bees(batch)
775
+ end
776
+
777
+ # Iterate over all records using batches
778
+ Honeybee.query(hive: "Queensville").find_each(batch_size: 100) do |bee|
779
+ bee.dispatch
780
+ end
781
+ ```
782
+
783
+ `.find_in_batches` yields collections of records (batches), while `.find_each`
784
+ yields individual records, but using batches behind the scenes.
785
+
786
+ Both methods accept a block-less form in which case they return an
787
+ `Enumerator`:
788
+
789
+ ```ruby
790
+ batch_enum = Honeybee.find_in_batches
791
+
792
+ batch = batch_enum.next # => Spyke::Collection
793
+
794
+ batch_enum.each do |batch|
795
+ process_batch(batch)
796
+ end
797
+
798
+ record_enum = Honeybee.find_each
799
+
800
+ record_enum.next # => Honeybee
801
+ ```
802
+
803
+ NOTE: By its nature, batch processing is subject to race conditions if other
804
+ processes are modifying the database.
805
+
806
+
749
807
  ### Container fields
750
808
 
751
809
  You can define container fields on your model class with `container`:
@@ -783,6 +841,7 @@ bee.photo.upload(filename_or_io) # Upload a file to the container
783
841
  * `:content_type` - The MIME content type to use (defaults to
784
842
  `application/octet-stream`)
785
843
 
844
+
786
845
  ### Script execution
787
846
 
788
847
  The Data API allows running scripts as part of many types of requests.
@@ -870,7 +929,7 @@ separately, under their matching key.
870
929
  ```ruby
871
930
  bee.save(script: { presort: "My Presort Script", after: "My Script" })
872
931
 
873
- Honeybee.last_request_metadata[:script]
932
+ Honeybee.last_request_metadata.script
874
933
  # => { after: { result: "oh hi", error: "0" }, presort: { result: "lo", error: "0" } }
875
934
  ```
876
935
 
@@ -884,7 +943,7 @@ is performed on that scope.
884
943
 
885
944
  ```ruby
886
945
  # Find one Honeybee record executing a presort and after script
887
- Honeybee.script(presort: ["My Presort Script", "parameter"], after: "My Script").find_one
946
+ Honeybee.script(presort: ["My Presort Script", "parameter"], after: "My Script").first
888
947
  ```
889
948
 
890
949
  The model class' `.last_request_metadata` will be set in case you need to get the result.
@@ -896,6 +955,32 @@ to retrieving single records, in that case you'll have to use
896
955
  `.last_request_metadata`.
897
956
 
898
957
 
958
+ ### Setting global field values
959
+
960
+ You can call `.set_globals` on any `FmRest::Spyke::Base` model to set glabal
961
+ field values on the database that model is configured for.
962
+
963
+ You can pass it either a hash of fully qualified field names
964
+ (table_name::field_name), or 1-level-deep nested hashes, with the outer being a
965
+ table name and the inner keys being the field names:
966
+
967
+ ```ruby
968
+ Honeybee.set_globals(
969
+ "beeTable::myVar" => "value",
970
+ "beeTable::myOtherVar" => "also a value"
971
+ )
972
+
973
+ # Equivalent to the above example
974
+ Honeybee.set_globals(beeTable: { myVar: "value", myOtherVar: "also a value" })
975
+
976
+ # Combined
977
+ Honeybee.set_globals(
978
+ "beeTable::myVar" => "value",
979
+ beeTable: { myOtherVar: "also a value" }
980
+ )
981
+ ```
982
+
983
+
899
984
  ## Logging
900
985
 
901
986
  If using fmrest-ruby + Spyke in a Rails app pretty log output will be set up
@@ -964,7 +1049,7 @@ FM Data API reference: https://fmhelp.filemaker.com/docs/18/en/dataapi/
964
1049
  | Get container data | Manual* | Yes |
965
1050
  | Upload container data | Manual* | Yes |
966
1051
  | Perform a find request | Manual* | Yes |
967
- | Set global field values | Manual* | No |
1052
+ | Set global field values | Manual* | Yes
968
1053
  | Run a script | Manual* | Yes |
969
1054
  | Run a script with another request | Manual* | Yes |
970
1055
 
@@ -23,8 +23,8 @@ Gem::Specification.new do |spec|
23
23
  spec.add_dependency 'faraday', '>= 0.9.0', '< 2.0'
24
24
  spec.add_dependency 'faraday_middleware', '>= 0.9.1', '< 2.0'
25
25
 
26
- spec.add_development_dependency "bundler", "~> 1.16"
27
- spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "bundler"
27
+ spec.add_development_dependency "rake"
28
28
  spec.add_development_dependency "rspec", "~> 3.0"
29
29
  spec.add_development_dependency "spyke"
30
30
  spec.add_development_dependency "webmock"
@@ -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
 
@@ -4,10 +4,19 @@ 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
10
+ class_attribute :fmrest_config, instance_writer: false, instance_predicate: false
11
+
12
+ # Overrides the fmrest_config reader created by class_attribute so we
13
+ # can default set the default at call time.
14
+ #
15
+ # This method gets overwriten in subclasses if self.fmrest_config= is
16
+ # called.
17
+ define_singleton_method(:fmrest_config) do
18
+ FmRest.default_connection_settings
19
+ end
11
20
 
12
21
  class_attribute :faraday_block, instance_accessor: false, instance_predicate: false
13
22
  class << self; private :faraday_block, :faraday_block=; end
@@ -40,7 +49,7 @@ module FmRest
40
49
  def fmrest_connection
41
50
  @fmrest_connection ||=
42
51
  begin
43
- config = fmrest_config || FmRest.default_connection_settings
52
+ config = fmrest_config
44
53
 
45
54
  FmRest::V1.build_connection(config) do |conn|
46
55
  faraday_block.call(conn) if faraday_block
@@ -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,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]
@@ -79,17 +79,24 @@ module FmRest
79
79
  class InvalidDate < ArgumentError; end
80
80
 
81
81
  class << self
82
- alias_method :strptime, :new
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
83
91
  end
84
92
 
85
- def initialize(str, date_format, **str_args)
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
+
86
97
  super(str, **str_args)
87
98
 
88
- begin
89
- @delegate = self.class::DELEGATE_CLASS.strptime(str, date_format)
90
- rescue ArgumentError
91
- raise InvalidDate
92
- end
99
+ @delegate = date
93
100
 
94
101
  freeze
95
102
  end
@@ -178,4 +185,36 @@ module FmRest
178
185
  @delegate
179
186
  end
180
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
181
220
  end
@@ -4,6 +4,7 @@ require "fmrest/v1/connection"
4
4
  require "fmrest/v1/paths"
5
5
  require "fmrest/v1/container_fields"
6
6
  require "fmrest/v1/utils"
7
+ require "fmrest/v1/dates"
7
8
 
8
9
  module FmRest
9
10
  module V1
@@ -15,5 +16,6 @@ module FmRest
15
16
  extend Paths
16
17
  extend ContainerFields
17
18
  extend Utils
19
+ extend Dates
18
20
  end
19
21
  end
@@ -31,10 +31,6 @@ module FmRest
31
31
  conn.request :multipart
32
32
  conn.request :json
33
33
 
34
- if options[:log]
35
- conn.response :logger, nil, bodies: true, headers: true
36
- end
37
-
38
34
  # Allow overriding the default response middleware
39
35
  if block_given?
40
36
  yield conn, options
@@ -43,6 +39,10 @@ module FmRest
43
39
  conn.response :json
44
40
  end
45
41
 
42
+ if options[:log]
43
+ conn.response :logger, nil, bodies: true, headers: true
44
+ end
45
+
46
46
  conn.adapter Faraday.default_adapter
47
47
  end
48
48
  end
@@ -74,8 +74,10 @@ module FmRest
74
74
  faraday_options[:ssl] = options[:ssl] if options.key?(:ssl)
75
75
  faraday_options[:proxy] = options[:proxy] if options.key?(:proxy)
76
76
 
77
+ database = URI.encode_www_form_component(options.fetch(:database))
78
+
77
79
  Faraday.new(
78
- "#{scheme}://#{host}#{BASE_PATH}/#{URI.escape(options.fetch(:database))}/".freeze,
80
+ "#{scheme}://#{host}#{BASE_PATH}/#{database}/".freeze,
79
81
  faraday_options,
80
82
  &block
81
83
  )
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module V1
5
+ module Dates
6
+ FM_DATETIME_FORMAT_MATCHER = /MM|mm|dd|HH|ss|yyyy/.freeze
7
+
8
+ FM_DATE_TO_STRPTIME_SUBSTITUTIONS = {
9
+ "MM" => "%m",
10
+ "dd" => "%d",
11
+ "yyyy" => "%Y",
12
+ "HH" => "%H",
13
+ "mm" => "%M",
14
+ "ss" => "%S"
15
+ }.freeze
16
+
17
+ FM_DATE_TO_REGEXP_SUBSTITUTIONS = {
18
+ "MM" => '(?:0[1-9]|1[012])',
19
+ "dd" => '(?:0[1-9]|[12][0-9]|3[01])',
20
+ "yyyy" => '\d{4}',
21
+ "HH" => '(?:[01]\d|2[0123])',
22
+ "mm" => '[0-5]\d',
23
+ "ss" => '[0-5]\d'
24
+ }.freeze
25
+
26
+ def self.extended(mod)
27
+ mod.instance_eval do
28
+ @date_strptime = {}
29
+ @date_regexp = {}
30
+ end
31
+ end
32
+
33
+ # Converts a FM date-time format to `DateTime.strptime` format
34
+ #
35
+ # @param fm_format [String] The FileMaker date-time format
36
+ # @return [String] The `DateTime.strpdate` equivalent of the given FM
37
+ # date-time format
38
+ def fm_date_to_strptime_format(fm_format)
39
+ @date_strptime[fm_format] ||=
40
+ fm_format.gsub(FM_DATETIME_FORMAT_MATCHER, FM_DATE_TO_STRPTIME_SUBSTITUTIONS).freeze
41
+ end
42
+
43
+ # Converts a FM date-time format to a Regexp. This is mostly used a
44
+ # quicker way of checking whether a FM field is a date field than
45
+ # Date|DateTime.strptime
46
+ #
47
+ # @param fm_format [String] The FileMaker date-time format
48
+ # @return [Regexp] A reegular expression matching strings in the given FM
49
+ # date-time format
50
+ def fm_date_to_regexp(fm_format)
51
+ @date_regexp[fm_format] ||=
52
+ Regexp.new('\A' + fm_format.gsub(FM_DATETIME_FORMAT_MATCHER, FM_DATE_TO_REGEXP_SUBSTITUTIONS) + '\Z').freeze
53
+ end
54
+
55
+ # Takes a DateTime dt, and returns the correct local offset for that dt,
56
+ # daylight savings included, in fraction of a day.
57
+ #
58
+ # By default, if ActiveSupport's Time.zone is set it will be used instead
59
+ # of the system timezone.
60
+ #
61
+ # @param dt [DateTime] The DateTime to get the offset for
62
+ # @param zone [nil, String, TimeZone] The timezone to use to calculate
63
+ # the offset (defaults to system timezone, or ActiveSupport's Time.zone
64
+ # if set)
65
+ # @return [Rational] The offset in fraction of a day
66
+ def local_offset_for_datetime(dt, zone = nil)
67
+ dt = dt.new_offset(0)
68
+ time = ::Time.utc(dt.year, dt.month, dt.day, dt.hour, dt.min, dt.sec)
69
+
70
+ # Do we have ActiveSupport's TimeZone?
71
+ time = if time.respond_to?(:in_time_zone)
72
+ time.in_time_zone(zone || ::Time.zone)
73
+ else
74
+ time.localtime
75
+ end
76
+
77
+ Rational(time.utc_offset, 86400) # seconds in one day (24*60*60)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -5,7 +5,8 @@ require "fmrest/string_date"
5
5
  module FmRest
6
6
  module V1
7
7
  class TypeCoercer < Faraday::Response::Middleware
8
- # We use this date to represent a time for consistency with ginjo-rfm
8
+ # We use this date to represent a FileMaker time for consistency with
9
+ # ginjo-rfm
9
10
  JULIAN_ZERO_DAY = "-4712/1/1"
10
11
 
11
12
  COERCE_HYBRID = [:hybrid, "hybrid", true].freeze
@@ -30,15 +31,13 @@ module FmRest
30
31
  field_data = record["fieldData"] || record[:fieldData]
31
32
  portal_data = record["portalData"] || record[:portalData]
32
33
 
33
- # Build an enumerator that iterates over hashes of fields
34
- enum = Enumerator.new { |y| y << field_data }
35
- if portal_data
36
- portal_data.each_value do |portal_records|
37
- enum += portal_records.to_enum
34
+ coerce_fields(field_data)
35
+
36
+ portal_data.try(:each_value) do |portal_records|
37
+ portal_records.each do |pr|
38
+ coerce_fields(pr)
38
39
  end
39
40
  end
40
-
41
- enum.each { |hash| coerce_fields(hash) }
42
41
  end
43
42
  end
44
43
 
@@ -49,29 +48,49 @@ module FmRest
49
48
  next unless v.is_a?(String)
50
49
  next if k == "recordId" || k == :recordId || k == "modId" || k == :modId
51
50
 
52
- begin
53
- str_timestamp = datetime_class.strptime(v, datetime_format)
54
- hash[k] = str_timestamp
55
- next
56
- rescue ArgumentError
51
+ if quick_check_timestamp(v)
52
+ begin
53
+ hash[k] = coerce_timestamp(v)
54
+ next
55
+ rescue ArgumentError
56
+ end
57
57
  end
58
58
 
59
- begin
60
- str_date = date_class.strptime(v, date_format)
61
- hash[k] = str_date
62
- next
63
- rescue ArgumentError
59
+ if quick_check_date(v)
60
+ begin
61
+ hash[k] = date_class.strptime(v, date_strptime_format)
62
+ next
63
+ rescue ArgumentError
64
+ end
64
65
  end
65
66
 
66
- begin
67
- str_time = datetime_class.strptime("#{JULIAN_ZERO_DAY} #{v}", time_format)
68
- hash[k] = str_time
69
- next
70
- rescue ArgumentError
67
+ if quick_check_time(v)
68
+ begin
69
+ hash[k] = datetime_class.strptime("#{JULIAN_ZERO_DAY} #{v}", time_strptime_format)
70
+ next
71
+ rescue ArgumentError
72
+ end
71
73
  end
72
74
  end
73
75
  end
74
76
 
77
+ def coerce_timestamp(str)
78
+ str_timestamp = DateTime.strptime(str, datetime_strptime_format)
79
+
80
+ if local_timezone?
81
+ # Change the DateTime to the local timezone, keeping the same
82
+ # time and just modifying the timezone
83
+ offset = FmRest::V1.local_offset_for_datetime(str_timestamp)
84
+ str_timestamp = str_timestamp.new_offset(offset) - offset
85
+ end
86
+
87
+ if datetime_class == StringDateTime
88
+ str_timestamp = StringDateTime.new(str, str_timestamp)
89
+ end
90
+
91
+ str_timestamp
92
+ end
93
+
75
94
  def date_class
76
95
  @date_class ||=
77
96
  case coerce_dates
@@ -92,19 +111,75 @@ module FmRest
92
111
  end
93
112
  end
94
113
 
95
- def date_format
96
- @date_format ||=
97
- FmRest::V1.convert_date_time_format(@options[:date_format] || DEFAULT_DATE_FORMAT)
114
+ def date_fm_format
115
+ @options[:date_format] || DEFAULT_DATE_FORMAT
116
+ end
117
+
118
+ def timestamp_fm_format
119
+ @options[:timestamp_format] || DEFAULT_TIMESTAMP_FORMAT
120
+ end
121
+
122
+ def time_fm_format
123
+ @options[:time_format] || DEFAULT_TIME_FORMAT
98
124
  end
99
125
 
100
- def datetime_format
101
- @datetime_format ||=
102
- FmRest::V1.convert_date_time_format(@options[:timestamp_format] || DEFAULT_TIMESTAMP_FORMAT)
126
+ def date_strptime_format
127
+ FmRest::V1.fm_date_to_strptime_format(date_fm_format)
128
+ end
129
+
130
+ def datetime_strptime_format
131
+ FmRest::V1.fm_date_to_strptime_format(timestamp_fm_format)
132
+ end
133
+
134
+ def time_strptime_format
135
+ @time_strptime_format ||=
136
+ "%Y/%m/%d " + FmRest::V1.fm_date_to_strptime_format(time_fm_format)
137
+ end
138
+
139
+ # We use a string length test, followed by regexp match test to try to
140
+ # identify date fields. Benchmarking shows this should be between 1 and 3
141
+ # orders of magnitude faster for fails (i.e. non-dates) than just using
142
+ # Date.strptime.
143
+ #
144
+ # user system total real
145
+ # strptime: 0.268496 0.000962 0.269458 ( 0.270865)
146
+ # re=~: 0.024872 0.000070 0.024942 ( 0.025057)
147
+ # re.match?: 0.019745 0.000095 0.019840 ( 0.020058)
148
+ # strptime fail: 0.141309 0.000354 0.141663 ( 0.142266)
149
+ # re=~ fail: 0.031637 0.000095 0.031732 ( 0.031872)
150
+ # re.match? fail: 0.011249 0.000056 0.011305 ( 0.011375)
151
+ # length fail: 0.007177 0.000024 0.007201 ( 0.007222)
152
+ #
153
+ # NOTE: The faster Regexp#match? was introduced in Ruby 2.4.0, so we
154
+ # can't really rely on it being available
155
+ if //.respond_to?(:match?)
156
+ def quick_check_timestamp(v)
157
+ v.length == timestamp_fm_format.length && FmRest::V1::fm_date_to_regexp(timestamp_fm_format).match?(v)
158
+ end
159
+
160
+ def quick_check_date(v)
161
+ v.length == date_fm_format.length && FmRest::V1::fm_date_to_regexp(date_fm_format).match?(v)
162
+ end
163
+
164
+ def quick_check_time(v)
165
+ v.length == time_fm_format.length && FmRest::V1::fm_date_to_regexp(time_fm_format).match?(v)
166
+ end
167
+ else
168
+ def quick_check_timestamp(v)
169
+ v.length == timestamp_fm_format.length && FmRest::V1::fm_date_to_regexp(timestamp_fm_format) =~ v
170
+ end
171
+
172
+ def quick_check_date(v)
173
+ v.length == date_fm_format.length && FmRest::V1::fm_date_to_regexp(date_fm_format) =~ v
174
+ end
175
+
176
+ def quick_check_time(v)
177
+ v.length == time_fm_format.length && FmRest::V1::fm_date_to_regexp(time_fm_format) =~ v
178
+ end
103
179
  end
104
180
 
105
- def time_format
106
- @time_format ||=
107
- "%Y/%m/%d " + FmRest::V1.convert_date_time_format(@options[:time_format] || DEFAULT_TIME_FORMAT)
181
+ def local_timezone?
182
+ @local_timezone ||= @options.fetch(:timezone, nil).try(:to_sym) == :local
108
183
  end
109
184
 
110
185
  def coerce_dates
@@ -5,17 +5,6 @@ module FmRest
5
5
  module Utils
6
6
  VALID_SCRIPT_KEYS = [:prerequest, :presort, :after].freeze
7
7
 
8
- FM_DATETIME_FORMAT_MATCHER = /MM|mm|dd|HH|ss|yyyy/.freeze
9
-
10
- FM_DATETIME_FORMAT_SUBSTITUTIONS = {
11
- "MM" => "%m",
12
- "dd" => "%d",
13
- "yyyy" => "%Y",
14
- "HH" => "%H",
15
- "mm" => "%M",
16
- "ss" => "%S"
17
- }.freeze
18
-
19
8
  # Converts custom script options to a hash with the Data API's expected
20
9
  # JSON script format.
21
10
  #
@@ -83,11 +72,6 @@ module FmRest
83
72
  params
84
73
  end
85
74
 
86
- # Converts a FM date-time format to `Date.strptime` format
87
- #
88
- def convert_date_time_format(fm_format)
89
- fm_format.gsub(FM_DATETIME_FORMAT_MATCHER, FM_DATETIME_FORMAT_SUBSTITUTIONS)
90
- end
91
75
 
92
76
  private
93
77
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FmRest
4
- VERSION = "0.7.0"
4
+ VERSION = "0.10.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fmrest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pedro Carbajal
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-05-11 00:00:00.000000000 Z
11
+ date: 2020-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -54,30 +54,30 @@ dependencies:
54
54
  name: bundler
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
- - - "~>"
57
+ - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: '1.16'
59
+ version: '0'
60
60
  type: :development
61
61
  prerelease: false
62
62
  version_requirements: !ruby/object:Gem::Requirement
63
63
  requirements:
64
- - - "~>"
64
+ - - ">="
65
65
  - !ruby/object:Gem::Version
66
- version: '1.16'
66
+ version: '0'
67
67
  - !ruby/object:Gem::Dependency
68
68
  name: rake
69
69
  requirement: !ruby/object:Gem::Requirement
70
70
  requirements:
71
- - - "~>"
71
+ - - ">="
72
72
  - !ruby/object:Gem::Version
73
- version: '10.0'
73
+ version: '0'
74
74
  type: :development
75
75
  prerelease: false
76
76
  version_requirements: !ruby/object:Gem::Requirement
77
77
  requirements:
78
- - - "~>"
78
+ - - ">="
79
79
  - !ruby/object:Gem::Version
80
- version: '10.0'
80
+ version: '0'
81
81
  - !ruby/object:Gem::Dependency
82
82
  name: rspec
83
83
  requirement: !ruby/object:Gem::Requirement
@@ -247,6 +247,7 @@ files:
247
247
  - lib/fmrest/spyke/model/auth.rb
248
248
  - lib/fmrest/spyke/model/connection.rb
249
249
  - lib/fmrest/spyke/model/container_fields.rb
250
+ - lib/fmrest/spyke/model/global_fields.rb
250
251
  - lib/fmrest/spyke/model/http.rb
251
252
  - lib/fmrest/spyke/model/orm.rb
252
253
  - lib/fmrest/spyke/model/serialization.rb
@@ -265,6 +266,7 @@ files:
265
266
  - lib/fmrest/v1.rb
266
267
  - lib/fmrest/v1/connection.rb
267
268
  - lib/fmrest/v1/container_fields.rb
269
+ - lib/fmrest/v1/dates.rb
268
270
  - lib/fmrest/v1/paths.rb
269
271
  - lib/fmrest/v1/raise_errors.rb
270
272
  - lib/fmrest/v1/token_session.rb
@@ -277,7 +279,7 @@ homepage: https://github.com/beezwax/fmrest-ruby
277
279
  licenses:
278
280
  - MIT
279
281
  metadata: {}
280
- post_install_message:
282
+ post_install_message:
281
283
  rdoc_options: []
282
284
  require_paths:
283
285
  - lib
@@ -293,7 +295,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
293
295
  version: '0'
294
296
  requirements: []
295
297
  rubygems_version: 3.0.6
296
- signing_key:
298
+ signing_key:
297
299
  specification_version: 4
298
300
  summary: FileMaker Data API client using Faraday
299
301
  test_files: []