sequel-packer 0.1.0 → 0.2.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: 1420dc6158b5ec7447d40e2818513a0b73c948fe1f2c9dced0732f1ad10e97db
4
- data.tar.gz: e18293bafbe14d0af6c81ddde1e64e026498c61492bc26338bbc9fdbbd08507b
3
+ metadata.gz: 00bbe557f19cf23a18afe4e5d23173a3fb699977680fae2efa99670240f81847
4
+ data.tar.gz: 324688d708be91fa8e89eb99a65701f35dc8f3f5a799719588fa84adff877c95
5
5
  SHA512:
6
- metadata.gz: 43da46c32b0f2b068dc7c0c29022ea6ffebd6bf7becaae55185db95626779ed968c82f06d8edb424b6c33430edd58c53e44785ef0b1b9bdb240429b24b70aaa8
7
- data.tar.gz: 40d48122b9ce084f0c6b585b805481f2ef057ce2158a505782e2d2b0d2f58db4d1c4fd32a22de4f7178cbe82fdf6013694020558e305a1bd4845086648675731
6
+ metadata.gz: a626e68bd1053d818bd830a198bfe92b13bcbc0286a2ce985a996049ba7858a229aeabb00181e144fadfa8dbcc63ca90adef89619ddc32d86076ee53b718b424
7
+ data.tar.gz: 0344f7b561e830c58dd01c68da86728f57594a8073b920e4de70401e038e7a0cf92907f37726d84d9243268c12d3b256195df523d224224b69f53ee16f26136a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ### 0.2.0 (2020-05-13)
2
+
3
+ * Add support for `Sequel::Packer.eager(*associations)`
4
+ * Use `Sequel::Dataset.eager` in the background when fetching a dataset to avoid
5
+ N+1 query issues.
6
+
1
7
  ### 0.1.0 (2020-05-11)
2
8
 
3
9
  * Add traits.
data/README.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Sequel::Packer
2
2
 
3
- A Ruby serialization library to be used with the Sequel ORM.
3
+ `Sequel::Packer` is a Ruby JSON serialization library to be used with the Sequel
4
+ ORM offering the following features:
5
+
6
+ * *Declarative:* Define the shape of your serialized data with a simple,
7
+ straightforward DSL.
8
+ * *Flexible:* Certain contexts require different data. Packers provide an easy
9
+ way to opt-in to serializing certain data only when you need it. The library
10
+ also provides convenient escape hatches when you need to do something not
11
+ explicitly supported by the API.
12
+ * *Reusable:* The Packer library naturally composes well with itself. Nested
13
+ data can be serialized in the same way no matter what endpoint it's fetched
14
+ from.
15
+ * *Efficient:* When not using Sequel's [`TacticalEagerLoading`]
16
+ (https://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/TacticalEagerLoading.html)
17
+ plugin, the Packer library will intelligently determine which associations
18
+ and nested associations it needs to eager load in order to avoid any N+1 query
19
+ issues.
4
20
 
5
21
  ## Installation
6
22
 
@@ -156,8 +172,8 @@ class PostPacker < Sequel::Packer
156
172
  ...
157
173
 
158
174
  # Eww!
159
- - field :comments, CommentPacker
160
- + field :comments, CommentWithAuthorPacker
175
+ - field :comments, CommentPacker
176
+ + field :comments, CommentWithAuthorPacker
161
177
  end
162
178
  ```
163
179
 
@@ -173,10 +189,9 @@ class CommentPacker < Sequel::Packer
173
189
  field :id
174
190
  field :content
175
191
 
176
- # Added!
177
- trait :author do
178
- field :author, UserPacker
179
- end
192
+ + trait :author do
193
+ + field :author, UserPacker
194
+ + end
180
195
  end
181
196
  ```
182
197
 
@@ -214,8 +229,8 @@ class PostPacker < Sequel::Packer
214
229
  field :title
215
230
  field :content
216
231
 
217
- - field :comments, CommentPacker
218
- + field :comments, CommentPacker, :author
232
+ - field :comments, CommentPacker
233
+ + field :comments, CommentPacker, :author
219
234
  end
220
235
  ```
221
236
 
@@ -290,7 +305,7 @@ class MyPacker < Sequel::Packer
290
305
  end
291
306
  ```
292
307
 
293
- ### `self.field(association, packer_class, *traits)
308
+ ### `self.field(association, packer_class, *traits)`
294
309
 
295
310
  A Sequel association (defined in the model file using `one_to_many`, or
296
311
  `many_to_one`, etc.), can be packed using another Packer class, possibly with
@@ -318,6 +333,99 @@ field do |model, hash|
318
333
  end
319
334
  ```
320
335
 
336
+ ### `self.trait(trait_name, &block)`
337
+
338
+ Define optional serialization behavior by defining additional fields within a
339
+ `trait` block. Traits can be opted into when initializing a packer by passing
340
+ the name of the trait as an argument:
341
+
342
+ ```ruby
343
+ class MyPacker < Sequel::Packer
344
+ model MyObj
345
+ trait :my_trait do
346
+ field :my_optional_field
347
+ end
348
+ end
349
+
350
+ # packed objects don't have my_optional_field
351
+ MyPacker.new.pack(dataset)
352
+ # packed objects do have my_optional_field
353
+ MyPacker.new(:my_trait).pack(dataset)
354
+ ```
355
+
356
+ Traits can also be used when packing associations by passing the name of the
357
+ traits after the packer class:
358
+
359
+ ```ruby
360
+ class MyOtherPacker < Sequel::Packer
361
+ model MyObj
362
+ field :my_packers, MyPacker, :my_trait
363
+ end
364
+ ```
365
+
366
+ ### `self.eager(*associations)`
367
+
368
+ When packing an association, a Packer will automatically ensure that association
369
+ is eager loaded, but there may be cases when an association will be accessed
370
+ that the Packer doesn't know about. In these cases you can tell the Packer to
371
+ eager load that data by calling `eager(*associations)`, passing in arguments
372
+ the exact same way you would to [`Sequel::Dataset.eager`](
373
+ https://sequel.jeremyevans.net/rdoc/classes/Sequel/Model/Associations/DatasetMethods.html#method-i-eager).
374
+
375
+ One case where this may be useful is for a "count" field, that just lists the
376
+ number of associated objects, but doesn't actually return them:
377
+
378
+ ```ruby
379
+ class UserPacker < Sequel::Packer
380
+ model User
381
+
382
+ field :id
383
+
384
+ eager(:posts)
385
+ field(:num_posts) do |user|
386
+ user.posts.counts
387
+ end
388
+ end
389
+
390
+ UserPacker.new.pack(User.dataset)
391
+ => [
392
+ {id: 123, num_posts: 7},
393
+ {id: 456, num_posts: 3},
394
+ ...
395
+ ]
396
+ ```
397
+
398
+ This helps prevent N+1 query problems when not using Sequel's
399
+ [`TacticalEagerLoading`]
400
+ (https://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/TacticalEagerLoading.html)
401
+ plugin.
402
+
403
+ Another use of `eager`, even when using `TacticalEagerLoading`, is to modify or
404
+ limit which records gets fetched from the database by using an eager proc. For
405
+ example, to only pack recent posts, published in the past month, we might do:
406
+
407
+ ```
408
+ class UserPacker < Sequel::Packer
409
+ model User
410
+
411
+ field :id
412
+
413
+ trait :recent_posts do
414
+ eager posts: (proc {|ds| ds.where {created_at > Time.now - 1.month}})
415
+ field :posts, PostIdPacker
416
+ end
417
+ end
418
+ ```
419
+
420
+ Keep in mind that this limits the association that gets used by ALL fields, so
421
+ if another field actually needs access to all the users posts, it might not make
422
+ sense to use `eager`.
423
+
424
+ Also, it's important to note that if `eager` is called multiple times, with
425
+ multiple procs, each proc will get applied to the dataset, likely resulting in
426
+ overly restrictive filtering.
427
+
428
+
321
429
  ### `initialize(*traits)`
322
430
 
323
431
  When creating an instance of a Packer class, pass in any traits desired to
@@ -0,0 +1,173 @@
1
+ require 'sequel'
2
+
3
+ module Sequel
4
+ class Packer
5
+ module EagerHash
6
+ # An eager hash cannot have the form: {
7
+ # :assoc1 => :nested_assoc,
8
+ # <proc> => :assoc2,
9
+ # }
10
+ class MixedProcHashError < StandardError; end
11
+ # An eager hash cannot have multiple keys that are Procs.
12
+ class MultipleProcKeysError < StandardError; end
13
+ # If an eager hash contains a Proc as the key of a hash, that value at
14
+ # that key cannot be another hash with a Proc as a key.
15
+ class NestedEagerProcsError < StandardError; end
16
+
17
+ # Sequel's eager function can accept arguments in a number of different
18
+ # formats:
19
+ # .eager(:assoc)
20
+ # .eager([:assoc1, :assoc2])
21
+ # .eager(assoc: :nested_assoc)
22
+ # .eager(
23
+ # :assoc1,
24
+ # assoc2: {(proc {|ds| ...}) => [:nested_assoc1, :nested_assoc2]},
25
+ # )
26
+ #
27
+ # This method normalizes these arguments such that:
28
+ # - A Hash is returned
29
+ # - The keys of that hash are the names of associations
30
+ # - The values of that hash are either:
31
+ # - nil, representing no nested associations
32
+ # - a nested normalized hash, meeting these definitions
33
+ # - a "Proc hash", which is a hash with a single key, which is a proc,
34
+ # and whose value is either nil or a nested normalized hash.
35
+ #
36
+ # Notice that a normalized has the property that every value in the hash
37
+ # is either nil, or itself a normalized hash.
38
+ #
39
+ # Note that this method cannot return a "Proc hash" as the top level
40
+ # normalized hash; Proc hashes can only be nested under other
41
+ # associations.
42
+ def self.normalize_eager_args(*associations)
43
+ # Implementation largely borrowed from Sequel's
44
+ # eager_options_for_associations:
45
+ #
46
+ # https://github.com/jeremyevans/sequel/blob/5.32.0/lib/sequel/model/associations.rb#L3228-L3245
47
+ normalized_hash = {}
48
+
49
+ associations.flatten.each do |association|
50
+ case association
51
+ when Symbol
52
+ normalized_hash[association] = nil
53
+ when Hash
54
+ num_proc_keys = 0
55
+ num_symbol_keys = 0
56
+
57
+ association.each do |key, val|
58
+ case key
59
+ when Symbol
60
+ num_symbol_keys += 1
61
+ when Proc
62
+ num_proc_keys += 1
63
+
64
+ if val.is_a?(Proc) || is_proc_hash?(val)
65
+ raise(
66
+ NestedEagerProcsError,
67
+ "eager hash has nested Procs: #{associations.inspect}",
68
+ )
69
+ end
70
+ end
71
+
72
+ if val.nil?
73
+ # Already normalized
74
+ normalized_hash[key] = nil
75
+ elsif val.is_a?(Proc)
76
+ # Convert Proc value to a Proc hash.
77
+ normalized_hash[key] = {val => nil}
78
+ else
79
+ # Otherwise recurse.
80
+ normalized_hash[key] = normalize_eager_args(val)
81
+ end
82
+ end
83
+
84
+ if num_proc_keys > 1
85
+ raise(
86
+ MultipleProcKeysError,
87
+ "eager hash has multiple Proc keys: #{associations.inspect}",
88
+ )
89
+ end
90
+
91
+ if num_proc_keys > 0 && num_symbol_keys > 0
92
+ raise(
93
+ MixedProcHashError,
94
+ 'eager hash has both symbol keys and Proc keys: ' +
95
+ associations.inspect,
96
+ )
97
+ end
98
+ else
99
+ raise(
100
+ Sequel::Error,
101
+ 'Associations must be in the form of a symbol or hash',
102
+ )
103
+ end
104
+ end
105
+
106
+ normalized_hash
107
+ end
108
+
109
+ def self.is_proc_hash?(hash)
110
+ return false if !hash.is_a?(Hash)
111
+ return false if hash.size != 1
112
+ hash.keys[0].is_a?(Proc)
113
+ end
114
+
115
+ # Merges two eager hashes together, without modifying either one.
116
+ def self.merge(hash1, hash2)
117
+ return deep_dup(hash2) if !hash1
118
+ merge!(deep_dup(hash1), hash2)
119
+ end
120
+
121
+ # Merges two eager hashes together, modifying the first hash, while
122
+ # leaving the second unmodified. Since the first argument may be nil,
123
+ # callers should still use the return value, rather than the first
124
+ # argument.
125
+ def self.merge!(hash1, hash2)
126
+ return hash1 if !hash2
127
+ return deep_dup(hash2) if !hash1
128
+
129
+ hash2.each do |key, val2|
130
+ if !hash1.key?(key)
131
+ hash1[key] = deep_dup(val2)
132
+ next
133
+ end
134
+
135
+ val1 = hash1[key]
136
+ h1_is_proc_hash = is_proc_hash?(val1)
137
+ h2_is_proc_hash = is_proc_hash?(val2)
138
+
139
+ case [h1_is_proc_hash, h2_is_proc_hash]
140
+ when [false, false]
141
+ hash1[key] = merge!(val1, val2)
142
+ when [true, false]
143
+ # We want to actually merge the hash the proc points to.
144
+ eager_proc, nested_hash = val1.entries[0]
145
+ hash1[key] = {eager_proc => merge!(nested_hash, val2)}
146
+ when [false, true]
147
+ # Same as above, but flipped. Notice the order of the arguments to
148
+ # merge! to ensure hash2 is not modified!
149
+ eager_proc, nested_hash = val2.entries[0]
150
+ hash1[key] = {eager_proc => merge!(val1, nested_hash)}
151
+ when [true, true]
152
+ # Create a new proc that applies both procs, then merge their
153
+ # respective hashes.
154
+ proc1, nested_hash1 = val1.entries[0]
155
+ proc2, nested_hash2 = val2.entries[0]
156
+
157
+ new_proc = (proc {|ds| proc2.call(proc1.call(ds))})
158
+
159
+ hash1[key] = {new_proc => merge!(nested_hash1, nested_hash2)}
160
+ end
161
+ end
162
+
163
+ hash1
164
+ end
165
+
166
+ # Creates a deep copy of an eager hash.
167
+ def self.deep_dup(hash)
168
+ return nil if !hash
169
+ hash.transform_values {|val| deep_dup(val)}
170
+ end
171
+ end
172
+ end
173
+ end
@@ -1,5 +1,5 @@
1
1
  module Sequel
2
2
  class Packer
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
data/lib/sequel/packer.rb CHANGED
@@ -8,6 +8,7 @@ module Sequel
8
8
  def self.inherited(subclass)
9
9
  subclass.instance_variable_set(:@fields, [])
10
10
  subclass.instance_variable_set(:@traits, {})
11
+ subclass.instance_variable_set(:@eager_hash, nil)
11
12
  end
12
13
 
13
14
  def self.model(klass)
@@ -194,9 +195,17 @@ module Sequel
194
195
  @traits[name] = block
195
196
  end
196
197
 
198
+ def self.eager(*associations)
199
+ @eager_hash = EagerHash.merge!(
200
+ @eager_hash,
201
+ EagerHash.normalize_eager_args(*associations),
202
+ )
203
+ end
204
+
197
205
  def initialize(*traits)
198
206
  @packers = nil
199
207
  @fields = traits.any? ? packer_fields.dup : packer_fields
208
+ @eager_hash = traits.any? ? EagerHash.deep_dup(packer_eager_hash) : packer_eager_hash
200
209
 
201
210
  traits.each do |trait|
202
211
  trait_block = trait_blocks[trait]
@@ -209,14 +218,23 @@ module Sequel
209
218
 
210
219
  @fields.each do |field_options|
211
220
  if field_options[:type] == ASSOCIATION_FIELD
212
- @packers ||= {}
213
- @packers[field_options[:name]] =
221
+ association = field_options[:name]
222
+ association_packer =
214
223
  field_options[:packer].new(*field_options[:packer_traits])
224
+
225
+ @packers ||= {}
226
+ @packers[association] = association_packer
227
+
228
+ @eager_hash = EagerHash.merge!(
229
+ @eager_hash,
230
+ {association => association_packer.send(:eager_hash)},
231
+ )
215
232
  end
216
233
  end
217
234
  end
218
235
 
219
236
  def pack(dataset)
237
+ dataset = dataset.eager(@eager_hash) if @eager_hash
220
238
  models = dataset.all
221
239
  pack_models(models)
222
240
  end
@@ -272,15 +290,30 @@ module Sequel
272
290
  }
273
291
  end
274
292
 
293
+ def eager_hash
294
+ @eager_hash
295
+ end
296
+
297
+ def eager(*associations)
298
+ @eager_hash = EagerHash.merge!(
299
+ @eager_hash,
300
+ EagerHash.normalize_eager_args(*associations),
301
+ )
302
+ end
303
+
275
304
  def packer_fields
276
305
  self.class.instance_variable_get(:@fields)
277
306
  end
278
307
 
308
+ def packer_eager_hash
309
+ self.class.instance_variable_get(:@eager_hash)
310
+ end
311
+
279
312
  def trait_blocks
280
313
  self.class.instance_variable_get(:@traits)
281
314
  end
282
315
  end
283
316
  end
284
317
 
318
+ require 'sequel/packer/eager_hash'
285
319
  require "sequel/packer/version"
286
-
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequel-packer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Julius Martinez
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-05-12 00:00:00.000000000 Z
11
+ date: 2020-05-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sequel
@@ -127,6 +127,7 @@ files:
127
127
  - bin/console
128
128
  - bin/setup
129
129
  - lib/sequel/packer.rb
130
+ - lib/sequel/packer/eager_hash.rb
130
131
  - lib/sequel/packer/version.rb
131
132
  - sequel-packer.gemspec
132
133
  homepage: https://github.com/PaulJuliusMartinez/sequel-packer