sequel-packer 0.1.0 → 0.2.0

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