fmrest 0.7.0 → 0.10.1

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 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: []