fmrest-spyke 0.14.0 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7208f6230222ff9227d220c31f445337ad1c09a485b4a6dd752f8fed5f94aef
4
- data.tar.gz: 2a7424c4c6bc8f2b6ab218ebaab92efa1da644616eaa650e347d680e6008209e
3
+ metadata.gz: 48d4905462bd97acb37ec58ef98ef8ef58cc4362aa52adfc496de521231f8937
4
+ data.tar.gz: f99542f9578cfdbb894f4a9a5e6635da077f272f7cfa73911cc1e1563604b7e1
5
5
  SHA512:
6
- metadata.gz: 3551a71b87620e499a666b70f3ca1ae7f39ecd67602c778628254b4155d3c3136650d69063b6d3e3d2d26ada8e16d261a0b16ba4186efc997d045f92a65fd322
7
- data.tar.gz: 43cca2d89fb4bd051d8fbe0d75f54f65960e707e93488e3815cb849a3637162e1b8ef570b1d9e501045cd29c8c1546c4dafbde6737a98647f888154fe482b890
6
+ metadata.gz: 4a9c6772950abe865cf776c4cf3bba052bdb49f744429e475555d18e16289c39ed14a65e8251ecceb324d0ddc5d96c637dc1ce16e924af20dd44135fac41dbd6
7
+ data.tar.gz: bf8b8ab7ec6b55daf2ba55a5930fcb2323cff7823853c671139ec1e30feda7a7bc81657ab511ef0aa75730e4e98e95d7562df3f2a44252a8bd550ad9c749d584
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## Changelog
2
2
 
3
+ ### 0.15.0
4
+
5
+ * Much improved querying API (see documentation on querying), adding new
6
+ `.query` capabilities, as well as two new methods: `.match` and `.or`
7
+
3
8
  ### 0.14.0
4
9
 
5
10
  * Aliased `FmRest::Spyke::Base` as `FmRest::Layout` (now preferred), and
data/README.md CHANGED
@@ -54,13 +54,28 @@ class Honeybee < FmRest::Layout("Honeybees Web")
54
54
  }
55
55
 
56
56
  # Mapped attributes
57
- attributes name: "Bee Name", age: "Bee Age"
57
+ attributes name: "Bee Name", age: "Bee Age", created_on: "Created On"
58
58
 
59
- # Portals
60
- has_portal :flowers
59
+ # Portal associations
60
+ has_portal :tasks
61
61
 
62
- # File container
62
+ # File containers
63
63
  container :photo, field_name: "Bee Photo"
64
+
65
+ # Scopes
66
+ scope :can_legally_fly, -> { query(age: ">18") }
67
+
68
+ # Client-side validations
69
+ validates :name, presence: true
70
+
71
+ # Callbacks
72
+ before_save :set_created_on
73
+
74
+ private
75
+
76
+ def set_created_on
77
+ self.created_on = Date.today
78
+ end
64
79
  end
65
80
 
66
81
  # Find a record by id
@@ -69,7 +84,7 @@ bee = Honeybee.find(9)
69
84
  bee.name = "Hutch"
70
85
 
71
86
  # Add a new record to portal
72
- bee.flowers.build(name: "Daisy")
87
+ bee.tasks.build(urgency: "Today")
73
88
 
74
89
  bee.save
75
90
  ```
@@ -129,9 +144,9 @@ You can also pass a `:log` option for basic request logging, see the section on
129
144
  Option | Description | Format | Default
130
145
  --------------------|--------------------------------------------|-----------------------------|--------
131
146
  `:host` | Hostname with optional port, e.g. `"example.com:9000"` | String | None
132
- `:database` | | String | None
133
- `:username` | | String | None
134
- `:password` | | String | None
147
+ `:database` | The name of the database to connect to | String | None
148
+ `:username` | A Data API-ready account | String | None
149
+ `:password` | Your password | String | None
135
150
  `:account_name` | Alias of `:username` | String | None
136
151
  `:ssl` | SSL options to be forwarded to Faraday | Faraday SSL options | None
137
152
  `:proxy` | Proxy options to be forwarded to Faraday | Faraday proxy options | None
@@ -288,7 +303,8 @@ class Honeybee < FmRest::Layout
288
303
  end
289
304
  ```
290
305
 
291
- Alternatively, you can set the layout name in the class definition line:
306
+ Alternatively, if you're inheriting from `FmRest::Layout` directly you can set
307
+ the layout name in the class definition line:
292
308
 
293
309
  ```ruby
294
310
  class Honeybee < FmRest::Layout("Honeybees Web")
@@ -388,8 +404,8 @@ Guides](https://guides.rubyonrails.org/active_model_basics.html#dirty).
388
404
  Since Spyke is API-agnostic it only provides a wide-purpose `.where` method for
389
405
  passing arbitrary parameters to the REST backend. fmrest-ruby however is well
390
406
  aware of its backend API, so it extends Spkye models with a bunch of useful
391
- querying methods: `.query`, `.limit`, `.offset`, `.sort`, `.portal`, `.script`,
392
- etc.
407
+ querying methods: `.query`, `.match`, `.omit`, `.limit`, `.offset`, `.sort`,
408
+ `.portal`, `.script`, etc.
393
409
 
394
410
  See the [main document on querying](docs/Querying.md) for detailed information
395
411
  on the query API methods.
data/lib/fmrest/spyke.rb CHANGED
@@ -1,12 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require "spyke"
5
- rescue LoadError => e
6
- e.message << " (Did you include Spyke in your Gemfile?)" unless e.message.frozen?
7
- raise e
8
- end
9
-
3
+ require "spyke"
10
4
  require "fmrest"
11
5
  require "fmrest/spyke/spyke_formatter"
12
6
  require "fmrest/spyke/model"
@@ -26,10 +26,10 @@ module FmRest
26
26
 
27
27
  class_methods do
28
28
  # Methods delegated to `FmRest::Spyke::Relation`
29
- delegate :limit, :offset, :sort, :order, :query, :omit, :portal,
30
- :portals, :includes, :with_all_portals, :without_portals,
31
- :script, :find_one, :first, :any, :find_some,
32
- :find_in_batches, :find_each, to: :all
29
+ delegate :limit, :offset, :sort, :order, :query, :match, :omit,
30
+ :portal, :portals, :includes, :with_all_portals, :without_portals,
31
+ :script, :find_one, :first, :any, :find_some, :find_in_batches,
32
+ :find_each, to: :all
33
33
 
34
34
  # Spyke override -- Use FmRest's Relation instead of Spyke's vanilla
35
35
  # one
@@ -4,9 +4,6 @@ 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"
9
-
10
7
  # Spyke override -- Return FM Data API's expected JSON format,
11
8
  # including only modified fields.
12
9
  #
@@ -79,10 +76,10 @@ module FmRest
79
76
  def serialize_values!(params)
80
77
  params.transform_values! do |value|
81
78
  case value
82
- when *datetime_classes
83
- convert_datetime_timezone(value.to_datetime).strftime(FM_DATETIME_FORMAT)
84
- when *date_classes
85
- value.strftime(FM_DATE_FORMAT)
79
+ when *FmRest::V1.datetime_classes
80
+ FmRest::V1.convert_datetime_timezone(value.to_datetime, fmrest_config.timezone).strftime(FmRest::V1::Dates::FM_DATETIME_FORMAT)
81
+ when *FmRest::V1.date_classes
82
+ value.strftime(FmRest::V1::Dates::FM_DATE_FORMAT)
86
83
  else
87
84
  value
88
85
  end
@@ -90,25 +87,6 @@ module FmRest
90
87
 
91
88
  params
92
89
  end
93
-
94
- def convert_datetime_timezone(dt)
95
- case fmrest_config.timezone
96
- when :utc, "utc"
97
- dt.new_offset(0)
98
- when :local, "local"
99
- dt.new_offset(FmRest::V1.local_offset_for_datetime(dt))
100
- when nil
101
- dt
102
- end
103
- end
104
-
105
- def datetime_classes
106
- [DateTime, Time, defined?(FmRest::StringDateTime) && FmRest::StringDateTime].compact
107
- end
108
-
109
- def date_classes
110
- [Date, defined?(FmRest::StringDate) && FmRest::StringDate].compact
111
- end
112
90
  end
113
91
  end
114
92
  end
@@ -5,6 +5,8 @@ module FmRest
5
5
  class Relation < ::Spyke::Relation
6
6
  SORT_PARAM_MATCHER = /(.*?)(!|__desc(?:end)?)?\Z/.freeze
7
7
 
8
+ class UnknownQueryKey < ArgumentError; end
9
+
8
10
  # NOTE: We need to keep limit, offset, sort, query and portal accessors
9
11
  # separate from regular params because FM Data API uses either "limit" or
10
12
  # "_limit" (or "_offset", etc.) as param keys depending on the type of
@@ -12,7 +14,7 @@ module FmRest
12
14
 
13
15
 
14
16
  attr_accessor :limit_value, :offset_value, :sort_params, :query_params,
15
- :included_portals, :portal_params, :script_params
17
+ :or_flag, :included_portals, :portal_params, :script_params
16
18
 
17
19
  def initialize(*_args)
18
20
  super
@@ -161,16 +163,111 @@ module FmRest
161
163
  portal(false)
162
164
  end
163
165
 
166
+ # Sets conditions for a find request. Conditions must be given in
167
+ # `{ field: condition }` format, where `condition` is normally a string
168
+ # sent raw to the Data API server, so you can use FileMaker find
169
+ # operators. You can also pass Ruby range or date/datetime objects for
170
+ # condition values, and they'll be converted to the appropriate Data API
171
+ # representation.
172
+ #
173
+ # Passing `omit: true` in a conditions set will negate all conditions in
174
+ # that set.
175
+ #
176
+ # You can modify the way conditions are added (i.e. through logical AND
177
+ # or OR) by pre-chaining `.or`. By default it adds conditions through
178
+ # logical AND.
179
+ #
180
+ # Note that because of the way the Data API works, logical AND conditions
181
+ # on a single field are not possible. Because of that, if you try to set
182
+ # two AND conditions for the same field, the previously existing one will
183
+ # be overwritten with the new condition.
184
+ #
185
+ # It is recommended that you learn how the Data API represents conditions
186
+ # in its find requests (i.e. an array of JSON objects with conditions on
187
+ # fields). This method internally uses that same representation, which
188
+ # you can view by inspecting the resulting relations. Understanding that
189
+ # representation will also make the limitations of this Ruby API clear.
190
+ #
191
+ # @example
192
+ # Person.query(name: "=Alice") # Simple query
193
+ # Person.query(age: (20..29)) # Query using a Ruby range
194
+ # Person.query(created_on: Date.today..Date.today-1)
195
+ # Person.query(name: "=Alice", age: ">20") # Query multiple fields (logical AND)
196
+ # Person.query(name: "=Alice").query(age: ">20") # Same effect as above example
197
+ # Person.query(name: "=Bob", omit: true) # Negate a query (i.e. find people not named Bob)
198
+ # Person.query(pets: { name: "=Snuggles" }) # Query portal fields
199
+ # Person.query({ name: "=Alice" }, { name: "=Bob" }) # Separate conditions through logical OR
200
+ # Person.query(name: "=Alice").or.query(name: "=Bob") # Same effect as above example
201
+ # @return [FmRest::Spyke::Relation] a new relation with the given find
202
+ # conditions applied
164
203
  def query(*params)
165
204
  with_clone do |r|
166
- r.query_params += params.flatten.map { |p| normalize_query_params(p) }
205
+ params = params.flatten.map { |p| normalize_query_params(p) }
206
+
207
+ if r.or_flag || r.query_params.empty?
208
+ r.query_params += params
209
+ r.or_flag = nil
210
+ elsif params.length > r.query_params.length
211
+ params[0, r.query_params.length].each_with_index do |p, i|
212
+ r.query_params[i].merge!(p)
213
+ end
214
+
215
+ remainder = params.length - r.query_params.length
216
+ r.query_params += params[-remainder, remainder]
217
+ else
218
+ params.each_with_index { |p, i| r.query_params[i].merge!(p) }
219
+ end
167
220
  end
168
221
  end
169
222
 
223
+ # Similar to `.query`, but sets exact string match queries (i.e.
224
+ # prefixes queries with ==) and escapes find operators in the given
225
+ # queries using `FmRest.e`.
226
+ #
227
+ # @example
228
+ # Person.query(email: "bob@example.com") # Find exact email
229
+ # @return [FmRest::Spyke::Relation] a new relation with the exact match
230
+ # conditions applied
231
+ def match(*params)
232
+ query(transform_query_values(params) { |v| "==#{FmRest::V1.escape_find_operators(v)}" })
233
+ end
234
+
235
+ # Negated version of `.query`, sets conditions to omit in a find request.
236
+ #
237
+ # This is the same as passing `omit: true` to `.query`.
238
+ #
239
+ # @return [FmRest::Spyke::Relation] a new relation with the given find
240
+ # conditions applied negated
170
241
  def omit(params)
171
242
  query(params.merge(omit: true))
172
243
  end
173
244
 
245
+ # Signals that the next query conditions to be set (through `.query`,
246
+ # `.match`, etc.) should be added as a logical OR relative to previously
247
+ # set conditions (rather than the default AND).
248
+ #
249
+ # In practice this means the JSON query request will have a new
250
+ # conditions object appended, e.g.:
251
+ #
252
+ # ```
253
+ # {"query": [{"field": "condition"}, {"field": "OR-added condition"}]}
254
+ # ```
255
+ #
256
+ # You can call this method with or without parameters. If parameters are
257
+ # given they will be passed down to `.query` (and those conditions
258
+ # immediately set), otherwise it just prepares the next
259
+ # conditions-setting method to use OR.
260
+ #
261
+ # @example
262
+ # # Add conditions directly in .or call:
263
+ # Person.query(name: "=Alice").or(name: "=Bob")
264
+ # # Add exact match conditions through method chaining
265
+ # Person.match(email: "alice@example.com").or.match(email: "bob@example.com")
266
+ def or(*params)
267
+ clone = with_clone { |r| r.or_flag = true }
268
+ params.empty? ? clone : clone.query(*params)
269
+ end
270
+
174
271
  # @return [Boolean] whether a query was set on this relation
175
272
  def has_query?
176
273
  query_params.present?
@@ -328,11 +425,91 @@ module FmRest
328
425
  next
329
426
  end
330
427
 
331
- # TODO: Raise ArgumentError if an attribute given as symbol isn't defiend
332
- if k.kind_of?(Symbol) && klass.mapped_attributes.has_key?(k)
333
- normalized[klass.mapped_attributes[k].to_s] = v
428
+ # Portal fields query (nested hash), e.g. { contact: { name: "Hutch" } }
429
+ if v.kind_of?(Hash)
430
+ if k.kind_of?(Symbol)
431
+ portal_key, = klass.portal_options.find { |_, opts| opts[:name].to_s == k.to_s }
432
+
433
+ if portal_key
434
+ portal_model = klass.associations[k].klass
435
+
436
+ portal_normalized = v.each_with_object({}) do |(pk, pv), h|
437
+ normalize_single_query_param_for_model(portal_model, pk, pv, h)
438
+ end
439
+
440
+ normalized.merge!(portal_normalized.transform_keys { |pf| "#{portal_key}::#{pf}" })
441
+ else
442
+ raise UnknownQueryKey, "No portal matches the query key `:#{k}` on #{klass.name}. If you are trying to use the literal string '#{k}' pass it as a string instead of a symbol."
443
+ end
444
+ else
445
+ normalized.merge!(v.transform_keys { |pf| "#{k}::#{pf}" })
446
+ end
447
+
448
+ next
449
+ end
450
+
451
+ # Attribute query (scalar values), e.g. { name: "Hutch" }
452
+ normalize_single_query_param_for_model(klass, k, v, normalized)
453
+ end
454
+ end
455
+
456
+ def normalize_single_query_param_for_model(model, k, v, hash)
457
+ if k.kind_of?(Symbol)
458
+ if model.mapped_attributes.has_key?(k)
459
+ hash[model.mapped_attributes[k].to_s] = format_query_condition(v)
460
+ else
461
+ raise UnknownQueryKey, "No attribute matches the query key `:#{k}` on #{model.name}. If you are trying to use the literal string '#{k}' pass it as a string instead of a symbol."
462
+ end
463
+ else
464
+ hash[k.to_s] = format_query_condition(v)
465
+ end
466
+ end
467
+
468
+ # Transforms various Ruby data types to FileMaker search condition
469
+ # strings
470
+ #
471
+ def format_query_condition(condition)
472
+ case condition
473
+ when nil
474
+ "=" # Search for empty field
475
+ when Range
476
+ format_range_condition(condition)
477
+ when *FmRest::V1.datetime_classes
478
+ FmRest::V1.convert_datetime_timezone(condition.to_datetime, klass.fmrest_config.timezone)
479
+ .strftime(FmRest::V1::Dates::FM_DATETIME_FORMAT)
480
+ when *FmRest::V1.date_classes
481
+ condition.strftime(FmRest::V1::Dates::FM_DATE_FORMAT)
482
+ else
483
+ condition
484
+ end
485
+ end
486
+
487
+ def format_range_condition(range)
488
+ if range.first.kind_of?(Numeric)
489
+ if range.first == Float::INFINITY || range.end == -Float::INFINITY
490
+ raise ArgumentError, "Can't search for a range that begins at +Infinity or ends at -Infinity"
491
+ elsif range.first == -Float::INFINITY
492
+ if range.end == Float::INFINITY || range.end.nil?
493
+ "*" # Search for non-empty field
494
+ else
495
+ range.exclude_end? ? "<#{range.end}" : "<=#{range.end}"
496
+ end
497
+ elsif range.end == Float::INFINITY || range.end.nil?
498
+ ">=#{range.first}"
499
+ elsif range.exclude_end? && range.last.respond_to?(:pred)
500
+ "#{range.first}..#{range.last.pred}"
334
501
  else
335
- normalized[k.to_s] = v
502
+ "#{range.first}..#{range.last}"
503
+ end
504
+ else
505
+ "#{format_query_condition(range.first)}..#{format_query_condition(range.last)}"
506
+ end
507
+ end
508
+
509
+ def transform_query_values(*params, &block)
510
+ params.flatten.map do |p|
511
+ p.transform_values do |v|
512
+ v.kind_of?(Hash) ? v.transform_values(&block) : yield(v)
336
513
  end
337
514
  end
338
515
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fmrest-spyke
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pedro Carbajal
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-23 00:00:00.000000000 Z
11
+ date: 2021-04-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fmrest-core
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - '='
18
18
  - !ruby/object:Gem::Version
19
- version: 0.14.0
19
+ version: 0.15.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 0.14.0
26
+ version: 0.15.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: spyke
29
29
  requirement: !ruby/object:Gem::Requirement