sequel-packer 0.3.0 → 1.0.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: 7f7666c0db1cac9f34075e40a398a5963b33e6756d38507953dc14c59d5712d2
4
- data.tar.gz: 10bf3ff431ba9fab4ff6bdfec84e51f1243cf3da1b5eff3ea66d6b7b45f94fbc
3
+ metadata.gz: 8782014d5d4fd8a6da590674ebc1d8d7a248f13ab80a1c571a0036f5a158af4f
4
+ data.tar.gz: d58397494def47b14aba23ce8f3dd2006936116e1273016f5bafb8e22c20809a
5
5
  SHA512:
6
- metadata.gz: 67d08fbd8aef15f7005ae59b411f87a237d83b53aeb26e7e92efff2a628e6ae29bf14724cfedea03f34b5f7cf61e4c8d28be6cc4e01027535d04519fa681f7e1
7
- data.tar.gz: c96e1f683319af1a20d6cb775f6b0ac8a479297947b6a26008082e4a8aa8e35a677de7d3a8f66a7a74fc57079fb8ecab1b678201a214f5768fc57eef61b4b150
6
+ metadata.gz: a30ae3faee56c47a4da2efcddb0a09cb5cbf892451968d010fddd2c3b937b59caadeb3128c78c4ca94d8cfaf0d2741755459fb1729c4f6fbb9b97f2787289201
7
+ data.tar.gz: 109e270732bec0bb7311f5d5f5a7e5f9c6f5f91e4a685bbdec5e20f28a38ef92093fd64d56067e6e64a3a718c2076df700b82e401f2004898aee846dc6cd879b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ### 1.0.1 (2021-08-02)
2
+
3
+ * Update internal method call to remove "Using the last argument as
4
+ keyword parameters is deprecated" warning.
5
+
6
+ ### 1.0.0 (2020-05-18)
7
+
8
+ * Version 1.0.0 release! No changes since 0.5.0 except some small changes to the
9
+ README.
10
+
11
+ ### 0.5.0 (2020-05-17)
12
+
13
+ * Add `**context` argument to `#pack` method, exposed as `@context` in blocks
14
+ passed to `field` and `trait`.
15
+ * Add `::with_context(&block)`, for accessing `@context` to use in additional
16
+ DSL calls, or modify data fetching.
17
+ * Update README some re-organization and table of contents.
18
+
19
+ ### 0.4.0 (2020-05-17)
20
+
21
+ * **_BREAKING CHANGE:_** `#pack_models` and `#pack_model` have been changed to
22
+ private methods. In their place `Sequel::Packer#pack` has been changed to
23
+ accept a dataset, an array of models or a single model, while still ensuring
24
+ eager loading takes place.
25
+ * Add `self.precompute(&block)` for performing bulk computations outside of
26
+ Packer paradigm.
27
+
1
28
  ### 0.3.0 (2020-05-14)
2
29
 
3
30
  * Add `self.set_association_packer(association, packer_class, *traits)` and
data/README.md CHANGED
@@ -1,24 +1,138 @@
1
1
  # Sequel::Packer
2
2
 
3
- `Sequel::Packer` is a Ruby JSON serialization library to be used with the Sequel
4
- ORM offering the following features:
3
+ `Sequel::Packer` is a Ruby serialization library to be used with the [Sequel
4
+ ORM](https://github.com/jeremyevans/sequel) with the following qualities:
5
5
 
6
- * *Declarative:* Define the shape of your serialized data with a simple,
6
+ * **Declarative:** Define the shape of your serialized data with a simple,
7
7
  straightforward DSL.
8
- * *Flexible:* Certain contexts require different data. Packers provide an easy
8
+ * **Flexible:** Certain contexts require different data. Packers provide an easy
9
9
  way to opt-in to serializing certain data only when you need it. The library
10
10
  also provides convenient escape hatches when you need to do something not
11
11
  explicitly supported by the API.
12
- * *Reusable:* The Packer library naturally composes well with itself. Nested
12
+ * **Reusable:** The Packer library naturally composes well with itself. Nested
13
13
  data can be serialized in the same way no matter what endpoint it's fetched
14
14
  from.
15
- * *Efficient:* When not using Sequel's
15
+ * **Efficient:** When not using Sequel's
16
16
  [`TacticalEagerLoading`](https://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/TacticalEagerLoading.html)
17
17
  plugin, the Packer library will intelligently determine which associations
18
18
  and nested associations it needs to eager load in order to avoid any N+1 query
19
19
  issues.
20
20
 
21
- ## Installation
21
+ ## Example
22
+
23
+ `Sequel::Packer` uses your existing `Sequel::Model` declarations and leverages
24
+ the use of associations to efficiently serialize data.
25
+
26
+ ```ruby
27
+ class User < Sequel::Model(:users)
28
+ one_to_many :posts
29
+ end
30
+ class Post < Sequel::Model(:posts); end
31
+ ```
32
+
33
+ Packer definitions use a simple domain-specific language (DSL) to declare which
34
+ fields to serialize:
35
+
36
+ ```ruby
37
+ class PostPacker < Sequel::Packer
38
+ model Post
39
+
40
+ field :id
41
+ field :title
42
+
43
+ trait :truncated_content do
44
+ field :truncated_content do |post|
45
+ post.content[0..Post::PREVIEW_LENGTH]
46
+ end
47
+ end
48
+ end
49
+
50
+ class UserPacker < Sequel::Packer
51
+ model User
52
+
53
+ field :id
54
+ field :name
55
+
56
+ trait :posts do
57
+ field :posts, PostPacker, :truncated_content
58
+ end
59
+ end
60
+ ```
61
+
62
+ Once defined, Packers are easy to use; just call `.pack` and pass in a Sequel
63
+ dataset, an array of models, or a single model, and get back Ruby hashes.
64
+ From there you can simply call `to_json` on the result!
65
+
66
+ ```ruby
67
+ UserPacker.pack(User.dataset)
68
+ => [
69
+ {id: 1, name: 'Paul'},
70
+ {id: 2, name: 'Julius'},
71
+ ...
72
+ ]
73
+
74
+ UserPacker.pack(User[1], :posts)
75
+ => {
76
+ id: 1,
77
+ name: 'Paul',
78
+ posts: [
79
+ {
80
+ id: 15,
81
+ title: 'Announcing Sequel::Packer!',
82
+ truncated_content: 'Sequel::Packer is a new gem...',
83
+ },
84
+ {
85
+ id: 21,
86
+ title: 'Postgres Internals',
87
+ truncated_content: 'I never quite understood autovacuum...',
88
+ },
89
+ ...
90
+ ],
91
+ }
92
+ ```
93
+
94
+ ## Contents
95
+
96
+ - [Example](#example)
97
+ - [Getting Started](#getting-started)
98
+ - [Installation](#installation)
99
+ - [Example Schema](#example-schema)
100
+ - [Basic Fields](#basic-fields)
101
+ - [Packing Associations by Nesting Packers](#packing-associations-by-nesting-packers)
102
+ - [Traits](#traits)
103
+ - [API Reference](#api-reference)
104
+ - [Using a Packer](#using-a-packer)
105
+ - [Defining a Packer](#defining-a-packer)
106
+ - [`self.model(sequel_model_class)`](#selfmodelsequel_model_class)
107
+ - [`self.field(column_name)` (or `self.field(method_name)`)](#selffieldcolumn_name-or-selffieldmethod_name)
108
+ - [`self.field(key, &block)`](#selffieldkey-block)
109
+ - [`self.field(association, subpacker, *traits)`](#selffieldassociation-subpacker-traits)
110
+ - [`self.field(&block)`](#selffieldblock)
111
+ - [`self.trait(trait_name, &block)`](#selftraittrait_name-block)
112
+ - [`self.eager(*associations)`](#selfeagerassociations)
113
+ - [`self.set_association_packer(association, subpacker, *traits)`](#selfset_association_packerassociation-subpacker-traits)
114
+ - [`self.pack_association(association, models)`](#selfpack_associationassociation-models)
115
+ - [`self.precompute(&block)`](#selfprecomputeblock)
116
+ - [Context](#context)
117
+ - [`self.with_context(&block)`](#selfwith_contextblock)
118
+ - [Potential Future Functionality](#potential-future-functionality)
119
+ - [Automatically Generated Type Declarations](#automatically-generated-type-declarations)
120
+ - [Lifecycle Hooks](#lifecycle-hooks)
121
+ - [Less Data Fetching](#less-data-fetching)
122
+ - [Other Enhancements](#other-enhancements)
123
+ - [Contributing](#contributing)
124
+ - [Development](#development)
125
+ - [Releases](#releases)
126
+ - [Attribution](#attribution)
127
+ - [License](#license)
128
+
129
+ ## Getting Started
130
+
131
+ This section will explain the basic use of `Sequel::Packer`. Check out the [API
132
+ Reference](#api-reference) for an exhaustive coverage of the API and more
133
+ detailed documentation.
134
+
135
+ ### Installation
22
136
 
23
137
  Add this line to your application's Gemfile:
24
138
 
@@ -34,9 +148,9 @@ Or install it yourself as:
34
148
 
35
149
  $ gem install sequel-packer
36
150
 
37
- ## Usage
151
+ ### Example Schema
38
152
 
39
- Suppose we have the following basic database schema:
153
+ Most of the following examples will use the following database schema:
40
154
 
41
155
  ```ruby
42
156
  DB.create_table(:users) do
@@ -72,7 +186,7 @@ end
72
186
  ### Basic Fields
73
187
 
74
188
  Suppose an endpoint wants to fetch all the ten most recent comments by a user.
75
- After validating the user id, we end up with the Sequel dataset represting the
189
+ After validating the user id, we end up with the Sequel dataset representing the
76
190
  data we want to return:
77
191
 
78
192
  ```ruby
@@ -97,7 +211,7 @@ end
97
211
  This can then be used as follows:
98
212
 
99
213
  ```ruby
100
- CommentPacker.new.pack(recent_comments)
214
+ CommentPacker.pack(recent_comments)
101
215
  => [
102
216
  {id: 536, content: "Great post, man!"},
103
217
  {id: 436, content: "lol"},
@@ -105,10 +219,10 @@ CommentPacker.new.pack(recent_comments)
105
219
  ]
106
220
  ```
107
221
 
108
- ### Packing associations by nesting Packers
222
+ ### Packing Associations by Nesting Packers
109
223
 
110
224
  Now, suppose that we want to fetch a post and all of its comments. We can do
111
- this by defining another packer for Post that uses the CommentPacker:
225
+ this by defining another packer for `Post` that uses the `CommentPacker`:
112
226
 
113
227
  ```ruby
114
228
  class PostPacker < Sequel::Packer
@@ -121,14 +235,15 @@ class PostPacker < Sequel::Packer
121
235
  end
122
236
  ```
123
237
 
124
- Since `post.comments` is an array of Sequel::Models and not a primitive value,
125
- we must tell the Packer how to serialize them using another packer. This is
126
- what the second argument in `field :comments, CommentPacker` is doing.
238
+ Since `post.comments` is an array of `Sequel::Models` and not a primitive value,
239
+ we must tell the Packer how to serialize them using another packer. The second
240
+ argument in `field :comments, CommentPacker` tells the `PostPacker` to use the
241
+ pack those comments using the `CommentPacker`.
127
242
 
128
243
  We can then use this as follows:
129
244
 
130
245
  ```ruby
131
- PostPacker.new.pack(Post.order(:id.desc).limit(1))
246
+ PostPacker.pack(Post[validated_id])
132
247
  => [
133
248
  {
134
249
  id: 682,
@@ -158,8 +273,8 @@ end
158
273
  ```
159
274
 
160
275
  We could now define a new packer, `CommentWithAuthorPacker`, and use that in the
161
- PostPacker instead, but then we'd have to redeclare all the other fields we want
162
- on a packed Comment:
276
+ `PostPacker` instead, but then we'd have to redeclare all the other fields we
277
+ want on a packed `Comment`:
163
278
 
164
279
  ```ruby
165
280
  class CommentWithAuthorPacker < Sequel::Packer
@@ -184,7 +299,7 @@ end
184
299
  Declaring these fields in two places could cause them to get out of sync
185
300
  as more fields are added. Instead, we will use a _trait_. A _trait_ is a
186
301
  way to define a set of fields that we only want to pack sometimes. Instead
187
- of defining a totally new packer, we can extend the CommentPacker as follows:
302
+ of defining a totally new packer, we can extend the `CommentPacker` as follows:
188
303
 
189
304
  ```ruby
190
305
  class CommentPacker < Sequel::Packer
@@ -199,18 +314,18 @@ class CommentPacker < Sequel::Packer
199
314
  end
200
315
  ```
201
316
 
202
- To use a trait, simply pass it in when creating the packer instance:
317
+ To use a trait, simply pass it in when calling `pack`:
203
318
 
204
319
  ```ruby
205
320
  # Without the trait
206
- CommentPacker.new.pack(Comment.dataset)
321
+ CommentPacker.pack(Comment.dataset)
207
322
  => [
208
323
  {id: 536, content: "Great post, man!"},
209
324
  ...
210
325
  ]
211
326
 
212
327
  # With the trait
213
- CommentPacker.new(:author).pack(Comment.dataset)
328
+ CommentPacker.pack(Comment.dataset, :author)
214
329
  => [
215
330
  {
216
331
  id: 536,
@@ -249,13 +364,64 @@ Custom packers are written by creating subclasses of `Sequel::Packer`. This
249
364
  class defines a DSL for declaring how a Sequel Model will be converted into a
250
365
  plain Ruby hash.
251
366
 
252
- ### `self.model(sequel_model_class)`
367
+ ### Using a Packer
368
+
369
+ Using a Packer is dead simple. There's a single class method:
370
+
371
+ ```ruby
372
+ self.pack(data, *traits, **context)
373
+ ```
374
+
375
+ `data` can be in the form of a Sequel dataset, an array of Sequel models, or
376
+ a single Sequel model. No matter which form the data is passed in, the Packer
377
+ class will ensure nested data is efficiently loaded.
378
+
379
+ To pack additional fields defined in a trait, pass the name of the trait as an
380
+ additional argument, e.g., `UserPacker.pack(users, :recent_posts)` to include
381
+ recent posts with each user.
382
+
383
+ Finally, additional context can be provided to the Packer by passing additional
384
+ keyword arguments to `pack`. This context is handled opaquely by the Packer, but
385
+ it can be accessed in the blocks passed to `field` declarations. Common uses of
386
+ `context` include passing in the current user making a request, or passing in
387
+ additional precomputed data.
388
+
389
+ The implementation of `pack` is very simple. It creates an instance of a Packer,
390
+ by passing in the traits and the context, then calls `pack` on that instance,
391
+ and passes in the data:
392
+
393
+ ```ruby
394
+ def self.pack(data, *traits, **context)
395
+ return nil if !data # small easy optimization to avoid unnecessary work
396
+ new(*traits, **context).pack(data)
397
+ end
398
+ ```
399
+
400
+ It simply combines a constructor and single exposed instance method:
401
+
402
+ #### `initialize(*traits, **context)`
403
+
404
+ #### `pack(data)`
405
+
406
+ One instantiated, the same Packer could be used to pack data multiple times.
407
+ This is unlikely to be needed, but the functionality is there.
408
+
409
+ ### Defining a Packer
410
+
411
+ #### `self.model(sequel_model_class)`
253
412
 
254
413
  The beginning of each Packer class must begin with `model MySequelModel`, which
255
414
  specifies which Sequel Model this Packer class will serialize. This is mostly
256
- to catch certain errors at load time, rather than at run time.
415
+ to catch certain errors at load time, rather than at run time:
416
+
417
+ ```ruby
418
+ class UserPacker < Sequel::Packer
419
+ model User
420
+ ...
421
+ end
422
+ ```
257
423
 
258
- ### `self.field(column_name)` (or `self.field(method_name)`)
424
+ #### `self.field(column_name)` (or `self.field(method_name)`)
259
425
 
260
426
  Defining the shape of the outputted data is done using the `field` method, which
261
427
  exists in four different variants. This first variant is the simplest. It simply
@@ -283,7 +449,7 @@ Then when `User.create(first_name: "Paul", last_name: "Martinez")` gets packed
283
449
  with `field :full_name` specified, the outputted hash will contain
284
450
  `full_name: "Paul Martinez"`.
285
451
 
286
- ### `self.field(key, &block)`
452
+ #### `self.field(key, &block)`
287
453
 
288
454
  A block can be passed to `field` to perform arbitrary computation and store the
289
455
  result under the specified `key`. The block will be passed the model as a single
@@ -309,7 +475,7 @@ class MyPacker < Sequel::Packer
309
475
  end
310
476
  ```
311
477
 
312
- ### `self.field(association, packer_class, *traits)`
478
+ #### `self.field(association, subpacker, *traits)`
313
479
 
314
480
  A Sequel association (defined in the model file using `one_to_many`, or
315
481
  `many_to_one`, etc.), can be packed using another Packer class, possibly with
@@ -317,15 +483,16 @@ multiple traits specified. A similar output could be generated by doing:
317
483
 
318
484
  ```ruby
319
485
  field :association do |model|
320
- packer_class.new(*traits).pack(model.association_dataset)
486
+ subpacker.pack(model.association_dataset, *traits)
321
487
  end
322
488
  ```
323
489
 
324
- Though this version of course would result in many more queries to the database,
325
- which are not required when using the shorthand form, and also requires creating
326
- a new instance of `packer_class` for every packed model.
490
+ This form is very inefficient though, because it would result in a new subpacker
491
+ getting instantiated for every packed model. Additionally, unless the subpacker
492
+ is declared up-front, the Packer won't know to eager load that association,
493
+ potentially resulting in many unnecessary database queries.
327
494
 
328
- ### `self.field(&block)`
495
+ #### `self.field(&block)`
329
496
 
330
497
  Passing a block but no `key` to `field` allows for arbitrary manipulation of the
331
498
  packed hash. The block will be passed the model and the partially packed hash.
@@ -338,7 +505,7 @@ field do |model, hash|
338
505
  end
339
506
  ```
340
507
 
341
- ### `self.trait(trait_name, &block)`
508
+ #### `self.trait(trait_name, &block)`
342
509
 
343
510
  Define optional serialization behavior by defining additional fields within a
344
511
  `trait` block. Traits can be opted into when initializing a packer by passing
@@ -347,15 +514,19 @@ the name of the trait as an argument:
347
514
  ```ruby
348
515
  class MyPacker < Sequel::Packer
349
516
  model MyObj
517
+ field :id
518
+
350
519
  trait :my_trait do
351
- field :my_optional_field
520
+ field :trait_field
352
521
  end
353
522
  end
354
523
 
355
- # packed objects don't have my_optional_field
356
- MyPacker.new.pack(dataset)
357
- # packed objects do have my_optional_field
358
- MyPacker.new(:my_trait).pack(dataset)
524
+ # packed objects don't have trait_field
525
+ MyPacker.pack(dataset)
526
+ => [{id: 1}, {id: 2}, ...]
527
+ # packed objects do have trait_field
528
+ MyPacker.pack(dataset, :my_trait)
529
+ => [{id: 1, trait_field: 'foo'}, {id: 2, trait_field: 'bar'}, ...]
359
530
  ```
360
531
 
361
532
  Traits can also be used when packing associations by passing the name of the
@@ -368,7 +539,7 @@ class MyOtherPacker < Sequel::Packer
368
539
  end
369
540
  ```
370
541
 
371
- ### `self.eager(*associations)`
542
+ #### `self.eager(*associations)`
372
543
 
373
544
  When packing an association, a Packer will automatically ensure that association
374
545
  is eager loaded, but there may be cases when an association will be accessed
@@ -392,7 +563,7 @@ class UserPacker < Sequel::Packer
392
563
  end
393
564
  end
394
565
 
395
- UserPacker.new.pack(User.dataset)
566
+ UserPacker.pack(User.dataset)
396
567
  => [
397
568
  {id: 123, num_posts: 7},
398
569
  {id: 456, num_posts: 3},
@@ -400,7 +571,7 @@ UserPacker.new.pack(User.dataset)
400
571
  ]
401
572
  ```
402
573
 
403
- This helps prevent N+1 query problems when not using Sequel's
574
+ Using `eager` can help prevent N+1 query problems when not using Sequel's
404
575
  [`TacticalEagerLoading`](https://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/TacticalEagerLoading.html)
405
576
  plugin.
406
577
 
@@ -421,22 +592,28 @@ class UserPacker < Sequel::Packer
421
592
  end
422
593
  ```
423
594
 
424
- Keep in mind that this limits the association that gets used by ALL fields, so
425
- if another field actually needs access to all the users posts, it might not make
426
- sense to use `eager`.
595
+ **IMPORTANT NOTE:** Eager procs are not guaranteed to be executed when passing
596
+ in models, rather than a dataset, to `pack`. Specifically, if the models already
597
+ have fetched the association, the Packer won't refetch it. Because of this, it's
598
+ good practice to use `set_association_packer` and `pack_association` (see next
599
+ section) in a `field` block and duplicate the filtering action.
600
+
601
+ Also keep in mind that this limits the association that gets used by ALL fields,
602
+ so if another field actually needs access to all the users posts, it might not
603
+ make sense to use `eager`.
427
604
 
428
- Also, it's important to note that if `eager` is called multiple times, with
429
- multiple procs, each proc will get applied to the dataset, likely resulting in
430
- overly restrictive filtering.
605
+ Additionally, it's important to note that if `eager` is called multiple times,
606
+ with multiple procs, each proc will get applied to the dataset, likely resulting
607
+ in overly restrictive filtering.
431
608
 
432
- ### `self.set_association_packer(association, packer_class, *traits)`
609
+ #### `self.set_association_packer(association, subpacker, *traits)`
433
610
 
434
611
  See `self.pack_association(association, models)` below.
435
612
 
436
- ### `self.pack_association(association, models)`
613
+ #### `self.pack_association(association, models)`
437
614
 
438
615
  The simplest way to pack an association is to use
439
- `self.field(association, packer_class, *traits)`, but sometimes this doesn't do
616
+ `self.field(association, subpacker, *traits)`, but sometimes this doesn't do
440
617
  exactly what we want. We may want to pack the association under a different key
441
618
  than the name of the association. Or we may only want to pack some of the
442
619
  associated models (and it may be difficult or impossible to express which subset
@@ -475,7 +652,7 @@ but if it is passed a single model, it will return just that packed model.
475
652
 
476
653
  Examples:
477
654
 
478
- #### Use a different field name than the name of the association
655
+ ##### Use a different field name than the name of the association
479
656
  ```ruby
480
657
  set_association_packer :ugly_internal_names, InternalPacker
481
658
  field :nice_external_names do |model|
@@ -483,7 +660,7 @@ field :nice_external_names do |model|
483
660
  end
484
661
  ```
485
662
 
486
- #### Pack a single instance of a `one_to_many` association
663
+ ##### Pack a single instance of a `one_to_many` association
487
664
  ```ruby
488
665
  class PostPacker < Sequel::Packer
489
666
  set_association_packer :comments, CommentPacker
@@ -493,34 +670,213 @@ class PostPacker < Sequel::Packer
493
670
  end
494
671
  ```
495
672
 
673
+ #### `self.precompute(&block)`
496
674
 
497
- ### `initialize(*traits)`
675
+ Occasionally packing a model may require a computation that doesn't fit in with
676
+ the rest of the Packer paradigm. This may be a Sequel query that is particularly
677
+ difficult to express as an association, or even a call to an external service.
678
+ If such a computation can be performed in bulk, then the `precompute` method can
679
+ be used as an entry point for that operation.
498
680
 
499
- When creating an instance of a Packer class, pass in any traits desired to
500
- specify what additional data should be packed, if any.
681
+ The `precompute` method will execute a given block and pass it all of the models
682
+ that will be packed using that packer. This block will be executed a single
683
+ time, even when called by a deeply nested packer.
501
684
 
502
- ### `pack(dataset)`
685
+ The `precompute` block is `instance_exec`ed in the context of the packer
686
+ instance, the result of any computation can be saved in a simple instance
687
+ variable (`@precomputed_result`) and later referenced inside the blocks that are
688
+ passed to `field` methods.
503
689
 
504
- After creating a new instance of a Packer class, call `packer.pack(dataset)` to
505
- materialize a dataset and convert it to an array of packed Ruby hashes.
690
+ As an example, suppose a video uploading platform performs additional video
691
+ processing on every uploaded video and exposes the status of that processing as
692
+ a separate service over the network, rather than directly with the upload
693
+ metadata in the database. `precompute` could be used as follows:
506
694
 
507
- ## Development
695
+ ```ruby
696
+ class VideoUploadPacker < Sequel::Packer
697
+ model VideoUpload
508
698
 
509
- After checking out the repo, run `bin/setup` to install dependencies. Then, run
510
- `rake test` to run the tests. You can also run `bin/console` for an interactive
511
- prompt that will allow you to experiment.
699
+ precompute do |video_uploads|
700
+ @processing_statuses = ResolutionService
701
+ .get_status_bulk(ids: video_uploads.map(&:id))
702
+ end
512
703
 
513
- To install this gem onto your local machine, run `bundle exec rake install`. To
514
- release a new version, update the version number in `version.rb`, and then run
515
- `bundle exec rake release`, which will create a git tag for the version, push
516
- git commits and tags, and push the `.gem` file to
517
- [rubygems.org](https://rubygems.org).
704
+ field :id
705
+ field :filename
706
+ field :processing_status do |video_upload|
707
+ @processing_statuses[video_upload.id]
708
+ end
709
+ end
710
+ ```
711
+
712
+ #### Instance method versions
713
+
714
+ In addition to the class method versions of `field`, `eager`,
715
+ `set_association_packer`, and `precompute`, there are also regular instance method
716
+ versions which take the exact same arguments. When writing a `trait` block, the
717
+ block is evaulated in the context of a new Packer instance and actually calls the
718
+ instance method versions instead.
719
+
720
+ ### Context
721
+
722
+ In addition to the data to be packed, and a set of traits, the `pack` method
723
+ also accepts arbitrary keyword arguments. This is referred to as `context` is
724
+ handled opaquely by the Packer. The data passed in here is saved as the
725
+ `@context` instance variable, which is then accessible from within the blocks
726
+ passed to `field`, `trait`, and `precompute`, for whatever purpose. It is also
727
+ automatically passed to any nested subpackers.
728
+
729
+ The most common usage for context would be to pass in the current user making
730
+ a request. It could then be used to pack permission levels about records, for
731
+ example.
732
+
733
+ ```ruby
734
+ class PostPacker < Sequel::Packer
735
+ model Post
736
+
737
+ eager :permissions
738
+ field :access_level do |post|
739
+ user_permission = post.permissions.find do |perm|
740
+ perm.user_id == @context[:user].id
741
+ end
742
+
743
+ user_permission.access_level
744
+ end
745
+ end
746
+ ```
747
+
748
+ You might notice something inefficient about the above code. Even though we only
749
+ want to look at the user's permission record, we fetch ALL of the permission
750
+ records for each Post. Ideally we would filter the `permissions` association
751
+ dataset when we call `eager`, but we don't have access to `@context` at that
752
+ point. This leads to the final DSL method available when writing a Packer:
753
+
754
+ #### `self.with_context(&block)`
755
+
756
+ You can pass a block to `with_context` that will be executed as soon as a Packer
757
+ instance is constructed. The block can access `@context` and can also call the
758
+ standard Packer DSL methods, `field`, `eager`, etc.
759
+
760
+ The above example could then be made more efficient as follows:
761
+
762
+ ```ruby
763
+ class PostPacker < Sequel::Packer
764
+ model Post
765
+
766
+ - eager :permissions
767
+ + with_context do
768
+ + eager permissions: (proc {|ds| ds.where(user_id: @context[:user].id)})
769
+ + end
770
+ end
771
+ ```
772
+
773
+ A very tricky usage of `with_context` (and not recommended...) would be to
774
+ control the traits used on subpackers:
775
+
776
+ ```ruby
777
+ class UserPacker < Sequel::Packer
778
+ model User
779
+
780
+ with_context do
781
+ field :comments, CommentPacker, *@context[:comment_traits]
782
+ end
783
+ end
784
+
785
+ UserPacker.pack(User.dataset, comment_traits: [])
786
+ => [{comments: [{id: 7}, ...]}]
787
+ UserPacker.pack(User.dataset, comment_traits: [:author])
788
+ => [{comments: [{id: 7, author: {id: 1, ...}}, ...]}]
789
+ UserPacker.pack(User.dataset, comment_traits: [:num_likes])
790
+ => [{comments: [{id: 7, likes: 53}, ...]}]
791
+ ```
792
+
793
+ ## Potential Future Functionality
794
+
795
+ The 1.0.0 version of the Packer library is flexible to support many use cases.
796
+ That said, of course there are ways to improve it! There are three main
797
+ improvements I can imagine adding:
798
+
799
+ ### Automatically Generated Type Declarations
800
+
801
+ It would be fairly easy to add generate type definitions by adding arguments to
802
+ `field`. Packers could produce [TypeScript
803
+ interface](https://www.typescriptlang.org/docs/handbook/interfaces.html)
804
+ declarations, and adding a simple build step to a CI pipeline could enforce type
805
+ safety across the frontend and backend. Or they could produce
806
+ [OpenAPI](http://spec.openapis.org/oas/v3.0.3) specifications, which could then
807
+ be used to automatically generate clients using something like
808
+ [Swagger](https://swagger.io/).
809
+
810
+ ### Lifecycle Hooks
811
+
812
+ It should be fairly easy to extend the Packer library using standard Ruby
813
+ features like subclassing, mixins, via `include`, or even monkey-patching. It
814
+ may be beneficial to have explicit hooks for common operations however, like
815
+ `before_fetch`, or `around_pack`. It's more likely that these hooks are needed
816
+ for logging and tracing capabilities, than for actual functionality, so I'd
817
+ like to see some real-world usage before committing to a specific style of
818
+ integration.
819
+
820
+ ### Less Data Fetching
821
+
822
+ Sequel by default fetches every column in a table, but a Packer knows (roughly)
823
+ what data is going to be used so it could only select the columns neede for
824
+ actual serialization, and limit how much data is actually fetched from the
825
+ database. I haven't done any benchmarking on this, so I'm not sure how much of
826
+ a benefit could be gained by this, but it would be interesting!
827
+
828
+ This could work roughly as follows:
829
+
830
+ * Start by fetching all columns that appear in simple `field(:column_name)`
831
+ declarations
832
+ * Add any columns need to fetch nested associations, or to re-asscociate fetched
833
+ records with their "parent" models, using the `left_key` and `right_key`
834
+ fields of the `AssociationReflections`
835
+ * Add a `column(*columns)` DSL method to explicitly fetch additional columns
836
+
837
+ ### Other Enhancements
838
+
839
+ Here are some other potential enhancements, though these are less fleshed out.
840
+
841
+ * Support not including a key in a hash if the associated value is nil, to
842
+ reduce size of outputted data.
843
+ * Support different casing of the outputted hashes, i.e., `snake_case` vs.
844
+ `camelCase`.
845
+ * Explicitly support different output formats, rather than just plain Ruby
846
+ hashes, such as [Protocol
847
+ Buffers](https://developers.google.com/protocol-buffers) or [Cap'n
848
+ Proto](https://capnproto.org/).
849
+ * When using nested `precompute` blocks, the Packer has to flatten the
850
+ associations of a model, which may be expensive, but has not been benchmarked.
851
+ These flattened arrays already exist internally in Sequel when the eager
852
+ loading occurs, but those aren't exposed. The code in Sequel could be
853
+ re-implemented as part of the library to avoid re-constructing those arrays.
518
854
 
519
855
  ## Contributing
520
856
 
521
857
  Bug reports and pull requests are welcome on GitHub at
522
858
  https://github.com/PaulJuliusMartinez/sequel-packer.
523
859
 
860
+ ### Development
861
+
862
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
863
+ `rake test` to run the tests. You can also run `bin/console` for an interactive
864
+ prompt that will allow you to experiment.
865
+
866
+ ### Releases
867
+
868
+ To release a new version, update the version number in
869
+ `lib/sequel/packer/version.rb`, update the `CHANGELOG.md` with new changes, then
870
+ run `rake release`, which which will create a git tag for the version, push git
871
+ commits and tags, and push the `.gem` file to
872
+ [rubygems.org](https://rubygems.org).
873
+
874
+ ## Attribution
875
+
876
+ [Karthik Viswanathan](https://github.com/karthikv) designed the original API
877
+ of the Packer library while at [Affinity](https://www.affinity.co/). This
878
+ library is a ground up rewrite which defines a very similar API, but shares no
879
+ code with the original implementation.
524
880
 
525
881
  ## License
526
882
 
data/lib/sequel/packer.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'sequel'
2
+
1
3
  module Sequel
2
4
  class Packer
3
5
  # For invalid arguments provided to the field class method.
@@ -7,14 +9,33 @@ module Sequel
7
9
  class AssociationDoesNotExistError < StandardError; end
8
10
  class InvalidAssociationPackerError < StandardError; end
9
11
  class UnknownTraitError < StandardError; end
12
+ class UnnecessaryWithContextError < StandardError; end
13
+ class NoAssociationSubpackerDefinedError < StandardError; end
10
14
 
15
+ # Think of this method as the "initialize" method for a Packer class.
16
+ # Every Packer class keeps track of the fields, traits, and other various
17
+ # operations defined using the DSL internally.
11
18
  def self.inherited(subclass)
12
- subclass.instance_variable_set(:@class_fields, [])
13
- subclass.instance_variable_set(:@class_traits, {})
14
- subclass.instance_variable_set(:@class_packers, {})
15
- subclass.instance_variable_set(:@class_eager_hash, nil)
19
+ subclass.instance_variable_set(:@model, @model)
20
+ subclass.instance_variable_set(:@class_fields, @class_fields&.dup || [])
21
+ subclass.instance_variable_set(:@class_traits, @class_traits&.dup || {})
22
+ subclass.instance_variable_set(:@class_packers, @class_packers&.dup || {})
23
+ subclass.instance_variable_set(
24
+ :@class_eager_hash,
25
+ EagerHash.deep_dup(@class_eager_hash),
26
+ )
27
+ subclass.instance_variable_set(
28
+ :@class_precomputations,
29
+ @class_precomputations&.dup || [],
30
+ )
31
+ subclass.instance_variable_set(
32
+ :@class_with_contexts,
33
+ @class_with_contexts&.dup || [],
34
+ )
16
35
  end
17
36
 
37
+ # Declare the type of Sequel::Model this Packer will be used for. Used to
38
+ # validate associations at declaration time.
18
39
  def self.model(klass)
19
40
  if !(klass < Sequel::Model)
20
41
  fail(
@@ -32,18 +53,36 @@ module Sequel
32
53
  METHOD_FIELD = :method_field
33
54
  # field(:foo, &block)
34
55
  BLOCK_FIELD = :block_field
35
- # field(:association, packer_class)
56
+ # field(:association, subpacker)
36
57
  ASSOCIATION_FIELD = :association_field
37
58
  # field(&block)
38
59
  ARBITRARY_MODIFICATION_FIELD = :arbitrary_modification_field
39
60
 
40
- def self.field(field_name=nil, packer_class=nil, *traits, &block)
61
+ # Declare a field to be packed in the output hash. This method can be called
62
+ # in multiple ways:
63
+ #
64
+ # field(:field_name)
65
+ # - Calls the method :field_name on a model and stores the result under the
66
+ # key :field_name in the packed hash.
67
+ #
68
+ # field(:field_name, &block)
69
+ # - Yields the model to the block and stores the result under the key
70
+ # :field_name in the packed hash.
71
+ #
72
+ # field(:association, subpacker, *traits)
73
+ # - Packs model.association using the designated subpacker with the
74
+ # specified traits.
75
+ #
76
+ # field(&block)
77
+ # - Yields the model and the partially packed hash to the block, allowing
78
+ # for arbitrary modification of the output hash.
79
+ def self.field(field_name=nil, subpacker=nil, *traits, &block)
41
80
  Validation.check_field_arguments(
42
- @model, field_name, packer_class, traits, &block)
43
- field_type = determine_field_type(field_name, packer_class, block)
81
+ @model, field_name, subpacker, traits, &block)
82
+ field_type = determine_field_type(field_name, subpacker, block)
44
83
 
45
84
  if field_type == ASSOCIATION_FIELD
46
- set_association_packer(field_name, packer_class, *traits)
85
+ set_association_packer(field_name, subpacker, *traits)
47
86
  end
48
87
 
49
88
  @class_fields << {
@@ -53,15 +92,10 @@ module Sequel
53
92
  }
54
93
  end
55
94
 
56
- def self.set_association_packer(association, packer_class, *traits)
57
- Validation.check_association_packer(
58
- @model, association, packer_class, traits)
59
- @class_packers[association] = [packer_class, traits]
60
- end
61
-
95
+ # Helper for determing a field type from the arguments to field.
62
96
  private_class_method def self.determine_field_type(
63
97
  field_name,
64
- packer_class,
98
+ subpacker,
65
99
  block
66
100
  )
67
101
  if block
@@ -71,7 +105,7 @@ module Sequel
71
105
  ARBITRARY_MODIFICATION_FIELD
72
106
  end
73
107
  else
74
- if packer_class
108
+ if subpacker
75
109
  ASSOCIATION_FIELD
76
110
  else
77
111
  METHOD_FIELD
@@ -79,6 +113,17 @@ module Sequel
79
113
  end
80
114
  end
81
115
 
116
+ # Register that nested models related to the packed model by association
117
+ # should be packed using the given subpacker with the specified traits.
118
+ def self.set_association_packer(association, subpacker, *traits)
119
+ Validation.check_association_packer(
120
+ @model, association, subpacker, traits)
121
+ @class_packers[association] = [subpacker, traits]
122
+ end
123
+
124
+ # Define a trait, a set of optional fields that can be packed in certain
125
+ # situations. The block can call main Packer DSL methods: field,
126
+ # set_association_packer, eager, or precompute.
82
127
  def self.trait(name, &block)
83
128
  if @class_traits.key?(name)
84
129
  raise ArgumentError, "Trait :#{name} already defined"
@@ -89,6 +134,14 @@ module Sequel
89
134
  @class_traits[name] = block
90
135
  end
91
136
 
137
+ # Specify additional eager loading that should take place when fetching data
138
+ # to be packed. Commonly used to add filters to association datasets via
139
+ # eager procs.
140
+ #
141
+ # Users should not assume when using eager procs that the proc actually gets
142
+ # executed. If models with their associations already loaded are passed to
143
+ # pack then the proc will never get processed. Any filtering logic should be
144
+ # duplicated within a field block.
92
145
  def self.eager(*associations)
93
146
  @class_eager_hash = EagerHash.merge!(
94
147
  @class_eager_hash,
@@ -96,23 +149,68 @@ module Sequel
96
149
  )
97
150
  end
98
151
 
99
- def initialize(*traits)
100
- @subpackers = nil
152
+ # Declare an arbitrary operation to be performed one all the data has been
153
+ # fetched. The block will be executed once and be passed all of the models
154
+ # that will be packed by this Packer, even if this Packer is nested as a
155
+ # subpacker of other packers. The block can save the result of the
156
+ # computation in an instance variable which can then be accessed in the
157
+ # blocks passed to field.
158
+ def self.precompute(&block)
159
+ if !block
160
+ raise ArgumentError, 'Sequel::Packer.precompute must be passed a block'
161
+ end
162
+ @class_precomputations << block
163
+ end
164
+
165
+ # Declare a block to be called after a Packer has been initialized with
166
+ # context. The block can call the common Packer DSL methods. It is most
167
+ # commonly used to pass eager procs that depend on the Packer context to
168
+ # eager.
169
+ def self.with_context(&block)
170
+ if !block
171
+ raise ArgumentError, 'Sequel::Packer.with_context must be passed a block'
172
+ end
173
+ @class_with_contexts << block
174
+ end
101
175
 
102
- # If there aren't any traits, we can just re-use the class variables.
103
- if traits.empty?
104
- @instance_fields = class_fields
105
- @instance_packers = class_packers
106
- @instance_eager_hash = class_eager_hash
107
- else
108
- @instance_fields = class_fields.dup
109
- @instance_packers = class_packers.dup
110
- @instance_eager_hash = EagerHash.deep_dup(class_eager_hash)
176
+ # Pack the given data with the specified traits and additional context.
177
+ # Context is automatically passed down to any subpackers.
178
+ #
179
+ # Data can be provided as a Sequel::Dataset, an array of Sequel::Models, a
180
+ # single Sequel::Model, or nil. Even when passing models that have already
181
+ # been materialized, eager loading will be used to efficiently fetch
182
+ # associations.
183
+ #
184
+ # Returns an array of packed hashes, or a single packed hash if a single
185
+ # model was passed in. Returns nil if nil was passed in.
186
+ def self.pack(data, *traits, **context)
187
+ return nil if !data
188
+ new(*traits, **context).pack(data)
189
+ end
190
+
191
+ # Initialize a Packer instance with the given traits and additional context.
192
+ # This Packer can then pack multiple datasets or models via the pack method.
193
+ def initialize(*traits, **context)
194
+ @context = context
195
+
196
+ @subpackers = {}
197
+
198
+ # Technically we only need to duplicate these fields if we modify any of
199
+ # them, but manually implementing some sort of copy-on-write functionality
200
+ # is messy and error prone.
201
+ @instance_fields = class_fields.dup
202
+ @instance_packers = class_packers.dup
203
+ @instance_eager_hash = EagerHash.deep_dup(class_eager_hash)
204
+ @instance_precomputations = class_precomputations.dup
205
+
206
+ class_with_contexts.each do |with_context_block|
207
+ self.instance_exec(&with_context_block)
111
208
  end
112
209
 
113
210
  # Evaluate trait blocks, which might add new fields to @instance_fields,
114
- # new packers to @instance_packers, and/or new associations to
115
- # @instance_eager_hash.
211
+ # new packers to @instance_packers, new associations to
212
+ # @instance_eager_hash, and/or new precomputations to
213
+ # @instance_precomputations.
116
214
  traits.each do |trait|
117
215
  trait_block = class_traits[trait]
118
216
  if !trait_block
@@ -123,10 +221,9 @@ module Sequel
123
221
  end
124
222
 
125
223
  # Create all the subpackers, and merge in their eager hashes.
126
- @instance_packers.each do |association, (packer_class, traits)|
127
- association_packer = packer_class.new(*traits)
224
+ @instance_packers.each do |association, (subpacker, traits)|
225
+ association_packer = subpacker.new(*traits, **@context)
128
226
 
129
- @subpackers ||= {}
130
227
  @subpackers[association] = association_packer
131
228
 
132
229
  @instance_eager_hash = EagerHash.merge!(
@@ -136,12 +233,77 @@ module Sequel
136
233
  end
137
234
  end
138
235
 
139
- def pack(dataset)
140
- dataset = dataset.eager(@instance_eager_hash) if @instance_eager_hash
141
- models = dataset.all
142
- pack_models(models)
236
+ # Pack the given data with the traits and additional context specified when
237
+ # the Packer instance was created.
238
+ #
239
+ # Data can be provided as a Sequel::Dataset, an array of Sequel::Models, a
240
+ # single Sequel::Model, or nil. Even when passing models that have already
241
+ # been materialized, eager loading will be used to efficiently fetch
242
+ # associations.
243
+ #
244
+ # Returns an array of packed hashes, or a single packed hash if a single
245
+ # model was passed in. Returns nil if nil was passed in.
246
+ def pack(data)
247
+ case data
248
+ when Sequel::Dataset
249
+ data = data.eager(@instance_eager_hash) if @instance_eager_hash
250
+ models = data.all
251
+
252
+ run_precomputations(models)
253
+ pack_models(models)
254
+ when Sequel::Model
255
+ if @instance_eager_hash
256
+ EagerLoading.eager_load(class_model, [data], @instance_eager_hash)
257
+ end
258
+
259
+ run_precomputations([data])
260
+ pack_model(data)
261
+ when Array
262
+ if @instance_eager_hash
263
+ EagerLoading.eager_load(class_model, data, @instance_eager_hash)
264
+ end
265
+
266
+ run_precomputations(data)
267
+ pack_models(data)
268
+ when NilClass
269
+ nil
270
+ end
143
271
  end
144
272
 
273
+ private
274
+
275
+ # Run any blocks declared using precompute on the given models, as well as
276
+ # any precompute blocks declared by subpackers.
277
+ def run_precomputations(models)
278
+ @instance_packers.each do |association, _|
279
+ subpacker = @subpackers[association]
280
+ next if !subpacker.send(:has_precomputations?)
281
+
282
+ reflection = class_model.association_reflection(association)
283
+
284
+ if reflection.returns_array?
285
+ all_associated_records = models.flat_map {|m| m.send(association)}.uniq
286
+ else
287
+ all_associated_records = models.map {|m| m.send(association)}.compact
288
+ end
289
+
290
+ subpacker.send(:run_precomputations, all_associated_records)
291
+ end
292
+
293
+ @instance_precomputations.each do |block|
294
+ instance_exec(models, &block)
295
+ end
296
+ end
297
+
298
+ # Check if a Packer has any precompute blocks declared, to avoid the
299
+ # overhead of flattening the child associations.
300
+ def has_precomputations?
301
+ return true if @instance_precomputations.any?
302
+ return false if !@subpackers
303
+ @subpackers.values.any? {|sp| sp.send(:has_precomputations?)}
304
+ end
305
+
306
+ # Pack a single model by processing all of the Packer's declared fields.
145
307
  def pack_model(model)
146
308
  h = {}
147
309
 
@@ -164,35 +326,45 @@ module Sequel
164
326
  h
165
327
  end
166
328
 
329
+ # Pack an array of models by processing all of the Packer's declared fields.
167
330
  def pack_models(models)
168
331
  models.map {|m| pack_model(m)}
169
332
  end
170
333
 
334
+ # Pack models from an association using the designated subpacker.
171
335
  def pack_association(association, associated_models)
172
336
  return nil if !associated_models
173
337
 
174
338
  packer = @subpackers[association]
175
339
 
340
+ if !packer
341
+ raise(
342
+ NoAssociationSubpackerDefinedError,
343
+ "pack_association called for the #{class_model}.#{association} " +
344
+ 'association, but no Packer has been set for that association.',
345
+ )
346
+ end
347
+
176
348
  if associated_models.is_a?(Array)
177
- packer.pack_models(associated_models)
349
+ packer.send(:pack_models, associated_models)
178
350
  else
179
- packer.pack_model(associated_models)
351
+ packer.send(:pack_model, associated_models)
180
352
  end
181
353
  end
182
354
 
183
- private
184
-
185
- def field(field_name=nil, packer_class=nil, *traits, &block)
355
+ # See the definition of self.field. This method accepts the exact same
356
+ # arguments. When fields are declared within trait blocks, this method is
357
+ # called rather than the class method.
358
+ def field(field_name=nil, subpacker=nil, *traits, &block)
186
359
  klass = self.class
187
- model = klass.instance_variable_get(:@model)
188
360
 
189
361
  Validation.check_field_arguments(
190
- model, field_name, packer_class, traits, &block)
362
+ class_model, field_name, subpacker, traits, &block)
191
363
  field_type =
192
- klass.send(:determine_field_type, field_name, packer_class, block)
364
+ klass.send(:determine_field_type, field_name, subpacker, block)
193
365
 
194
366
  if field_type == ASSOCIATION_FIELD
195
- set_association_packer(field_name, packer_class, *traits)
367
+ set_association_packer(field_name, subpacker, *traits)
196
368
  end
197
369
 
198
370
  @instance_fields << {
@@ -202,18 +374,19 @@ module Sequel
202
374
  }
203
375
  end
204
376
 
205
- def set_association_packer(association, packer_class, *traits)
206
- model = self.class.instance_variable_get(:@model)
377
+ # See the definition of self.set_association_packer. This method accepts the
378
+ # exact same arguments. When used within a trait block, this method is
379
+ # called rather than the class method.
380
+ def set_association_packer(association, subpacker, *traits)
207
381
  Validation.check_association_packer(
208
- model, association, packer_class, traits)
209
-
210
- @instance_packers[association] = [packer_class, traits]
211
- end
382
+ class_model, association, subpacker, traits)
212
383
 
213
- def eager_hash
214
- @instance_eager_hash
384
+ @instance_packers[association] = [subpacker, traits]
215
385
  end
216
386
 
387
+ # See the definition of self.eager. This method accepts the exact same
388
+ # arguments. When used within a trait block, this method is called rather
389
+ # than the class method.
217
390
  def eager(*associations)
218
391
  @instance_eager_hash = EagerHash.merge!(
219
392
  @instance_eager_hash,
@@ -221,6 +394,39 @@ module Sequel
221
394
  )
222
395
  end
223
396
 
397
+ # See the definition of self.precompute. This method accepts the exact same
398
+ # arguments. When used within a trait block, this method is called rather
399
+ # than the class method.
400
+ def precompute(&block)
401
+ if !block
402
+ raise ArgumentError, 'Sequel::Packer.precompute must be passed a block'
403
+ end
404
+ @instance_precomputations << block
405
+ end
406
+
407
+ # See the definition of self.with_context. This method accepts the exact
408
+ # same arguments. When used within a trait block, this method is called
409
+ # rather than the class method.
410
+ def with_context(&block)
411
+ raise(
412
+ UnnecessaryWithContextError,
413
+ 'There is no need to call with_context from within a trait block; ' +
414
+ '@context can be accessed directly.',
415
+ )
416
+ end
417
+
418
+ # Access the internal eager hash.
419
+ def eager_hash
420
+ @instance_eager_hash
421
+ end
422
+
423
+ # The following methods expose the class instance variables containing the
424
+ # core definition of the Packer.
425
+
426
+ def class_model
427
+ self.class.instance_variable_get(:@model)
428
+ end
429
+
224
430
  def class_fields
225
431
  self.class.instance_variable_get(:@class_fields)
226
432
  end
@@ -236,9 +442,18 @@ module Sequel
236
442
  def class_traits
237
443
  self.class.instance_variable_get(:@class_traits)
238
444
  end
445
+
446
+ def class_precomputations
447
+ self.class.instance_variable_get(:@class_precomputations)
448
+ end
449
+
450
+ def class_with_contexts
451
+ self.class.instance_variable_get(:@class_with_contexts)
452
+ end
239
453
  end
240
454
  end
241
455
 
242
456
  require 'sequel/packer/eager_hash'
457
+ require 'sequel/packer/eager_loading'
243
458
  require 'sequel/packer/validation'
244
459
  require "sequel/packer/version"
@@ -1,5 +1,3 @@
1
- require 'sequel'
2
-
3
1
  module Sequel
4
2
  class Packer
5
3
  module EagerHash
@@ -0,0 +1,102 @@
1
+ module Sequel
2
+ class Packer
3
+ module EagerLoading
4
+ # This methods allows eager loading associations _after_ a record, or
5
+ # multiple records, have been fetched from the database. It is useful when
6
+ # you know you will be accessing model associations, but models have
7
+ # already been materialized.
8
+ #
9
+ # This method accepts a normalized_eager_hash, as specified by
10
+ # Sequel::Packer::EagerHash.
11
+ #
12
+ # This method will handle procs used to filter association datasets, but
13
+ # if an association has already been loaded for every model, the dataset
14
+ # will not be refetched and the proc will not be applied.
15
+ #
16
+ # This method borrows a lot from the #eager_load Sequel::Dataset method
17
+ # defined in Sequels lib/sequel/model/associations.rb.
18
+ def self.eager_load(model_class, model_or_models, normalized_eager_hash)
19
+ models = model_or_models.is_a?(Array) ?
20
+ model_or_models :
21
+ [model_or_models]
22
+
23
+ # Cache to avoid building id maps multiple times.
24
+ key_hash = {}
25
+
26
+ normalized_eager_hash.each do |association, nested_associations|
27
+ eager_block = nil
28
+
29
+ if EagerHash.is_proc_hash?(nested_associations)
30
+ eager_block, nested_associations = nested_associations.entries[0]
31
+ end
32
+
33
+ reflection = model_class.association_reflections[association]
34
+
35
+ # If all of the models have already loaded the association, we'll just
36
+ # recursively call ::eager_load to load nested associations.
37
+ if models.all? {|m| m.associations.key?(association)}
38
+ if nested_associations
39
+ associated_records = if reflection.returns_array?
40
+ models.flat_map {|m| m.send(association)}.uniq
41
+ else
42
+ models.map {|m| m.send(association)}.compact
43
+ end
44
+
45
+ eager_load(
46
+ reflection.associated_class,
47
+ associated_records,
48
+ nested_associations,
49
+ )
50
+ end
51
+ else
52
+ key = reflection.eager_loader_key
53
+ id_map = nil
54
+
55
+ if key && !key_hash[key]
56
+ id_map = Hash.new {|h, k| h[k] = []}
57
+
58
+ models.each do |model|
59
+ case key
60
+ when Symbol
61
+ model_id = model.get_column_value(key)
62
+ id_map[model_id] << model if model_id
63
+ when Array
64
+ model_id = key.map {|col| model.get_column_value(col)}
65
+ id_map[model_id] << model if model_id.all?
66
+ else
67
+ raise(
68
+ Sequel::Error,
69
+ "unhandled eager_loader_key #{key.inspect} for " +
70
+ "association #{association}",
71
+ )
72
+ end
73
+ end
74
+ end
75
+
76
+ loader = reflection[:eager_loader]
77
+
78
+ loader.call(
79
+ key_hash: key_hash,
80
+ rows: models,
81
+ associations: nested_associations,
82
+ self: self,
83
+ eager_block: eager_block,
84
+ id_map: id_map,
85
+ )
86
+
87
+ if reflection[:after_load]
88
+ models.each do |model|
89
+ model.send(
90
+ :run_association_callbacks,
91
+ reflection,
92
+ :after_load,
93
+ model.associations[association],
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,5 +1,5 @@
1
1
  module Sequel
2
2
  class Packer
3
- VERSION = "0.3.0"
3
+ VERSION = "1.0.1"
4
4
  end
5
5
  end
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.3.0
4
+ version: 1.0.1
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-14 00:00:00.000000000 Z
11
+ date: 2021-08-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sequel
@@ -128,6 +128,7 @@ files:
128
128
  - bin/setup
129
129
  - lib/sequel/packer.rb
130
130
  - lib/sequel/packer/eager_hash.rb
131
+ - lib/sequel/packer/eager_loading.rb
131
132
  - lib/sequel/packer/validation.rb
132
133
  - lib/sequel/packer/version.rb
133
134
  - sequel-packer.gemspec