sequel-packer 0.4.0 → 0.5.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: 811c7b7319fa09e9885726f132a84e52d8944746288be9331c7d9ac83408537f
4
- data.tar.gz: 4a11f786524e3576470b744068fcd7735271743a79bc6874755f0e7e2eadf09a
3
+ metadata.gz: fa3f2c4a293512f8a86e4acaf01c77e0554a42eb5f8847a6c33bfe8bff03330b
4
+ data.tar.gz: 8abeec112ff65793ced679bbf1f7aa370c9f3f6f08bdb63b1d5adb7cfc259833
5
5
  SHA512:
6
- metadata.gz: 608fc72d310784fe7b8edbe0fcc196b3207f4ed652e33fa4d3625864eb6ffaebc9a4b5291046bad92f620b85b7bd6ec1831e445f1934f8dd1b32d31d82e38643
7
- data.tar.gz: 296826320aac8126a6c6f53ac3f4cac8828f87ae092428a9f346d8fea1faedcbc9c039f2e7e2ecb40f009d7e9568e75cc8ea2b93ab089ab9873e3eaa411d5ecb
6
+ metadata.gz: ea64c4d227fd832b232d2833ccae7948208c012cd2b890f44bed919af86c6f828dbfd28de0d925fed3e218f96837b409251bc6f9ea7e8d0ff7802da63d21313f
7
+ data.tar.gz: 93bd50aee1d410b117ee2c2993d85dc6c53bae5e5204b6be37d413ab160f92a120725cb6cfc6f6c00744a9c12ff1b8db30aea7f8c0593fc9a7fb3831cca258c0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ### 0.5.0 (2020-05-17)
2
+
3
+ * Add `**context` argument to `#pack` method, exposed as `@context` in blocks
4
+ passed to `field` and `trait`.
5
+ * Add `::with_context(&block)`, for accessing `@context` to use in additional
6
+ DSL calls, or modify data fetching.
7
+ * Update README some re-organization and table of contents.
8
+
1
9
  ### 0.4.0 (2020-05-17)
2
10
 
3
11
  * **_BREAKING CHANGE:_** `#pack_models` and `#pack_model` have been changed to
data/README.md CHANGED
@@ -3,22 +3,128 @@
3
3
  `Sequel::Packer` is a Ruby JSON serialization library to be used with the Sequel
4
4
  ORM offering the following features:
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](#example)
22
+ - [Getting Started](#getting-started)
23
+ - [Installation](#installation)
24
+ - [Example Schema](#example-schema)
25
+ - [Basic Fields](#basic-fields)
26
+ - [Packing Associations by Nesting Packers](#packing-associations-by-nesting-packers)
27
+ - [Traits](#traits)
28
+ - [API Reference](#api-reference)
29
+ - [Using a Packer](#using-a-packer)
30
+ - [Defining a Packer](#defining-a-packer)
31
+ - [`self.model(sequel_model_class)`](#selfmodelsequel_model_class)
32
+ - [`self.field(column_name)` (or `self.field(method_name)`)](#selffieldcolumn_name-or-selffieldmethod_name)
33
+ - [`self.field(key, &block)`](#selffieldkey-block)
34
+ - [`self.field(association, subpacker, *traits)`](#selffieldassociation-subpacker-traits)
35
+ - [`self.field(&block)`](#selffieldblock)
36
+ - [`self.trait(trait_name, &block)`](#selftraittrait_name-block)
37
+ - [`self.eager(*associations)`](#selfeagerassociations)
38
+ - [`self.set_association_packer(association, subpacker, *traits)`](#selfset_association_packerassociation-subpacker-traits)
39
+ - [`self.pack_association(association, models)`](#selfpack_associationassociation-models)
40
+ - [`self.precompute(&block)`](#selfprecomputeblock)
41
+ - [Context](#context)
42
+ - [`self.with_context(&block)`](#selfwith_contextblock)
43
+ - [Contributing](#contributing)
44
+ - [Development](#development)
45
+ - [Releases](#releases)
46
+ - [License](#license)
47
+
48
+ ## Example
49
+
50
+ `Sequel::Packer` uses your existing `Sequel::Model` declarations and leverages
51
+ the use of associations to efficiently serialize data.
52
+
53
+ ```ruby
54
+ class User < Sequel::Model(:users)
55
+ one_to_many :posts
56
+ end
57
+ class Post < Sequel::Model(:posts); end
58
+ ```
59
+
60
+ Packer definitions use a simple domain-specific language (DSL) to declare which
61
+ fields to serialize:
62
+
63
+ ```ruby
64
+ class PostPacker < Sequel::Packer
65
+ model Post
66
+
67
+ field :id
68
+ field :title
69
+
70
+ trait :truncated_content do
71
+ field :truncated_content do |post|
72
+ post.content[0..Post::PREVIEW_LENGTH]
73
+ end
74
+ end
75
+ end
76
+
77
+ class UserPacker < Sequel::Packer
78
+ model User
79
+
80
+ field :id
81
+ field :name
82
+
83
+ trait :posts do
84
+ field :posts, PostPacker, :truncated_content
85
+ end
86
+ end
87
+ ```
88
+
89
+ Once defined, Packers are easy to use; just call `.pack` and pass in a Sequel
90
+ dataset, an array of models, or a single model, and get back Ruby hashes.
91
+ Simply call `to_json` on the result!
92
+
93
+ ```ruby
94
+ UserPacker.pack(User.dataset)
95
+ => [
96
+ {id: 1, name: 'Paul'},
97
+ {id: 2, name: 'Julius'},
98
+ ...
99
+ ]
100
+
101
+ UserPacker.pack(User[1], :posts)
102
+ => {
103
+ id: 1,
104
+ name: 'Paul',
105
+ posts: [
106
+ {
107
+ id: 15,
108
+ title: 'Announcing Sequel::Packer!',
109
+ truncated_content: 'Sequel::Packer is a new gem...',
110
+ },
111
+ {
112
+ id: 21,
113
+ title: 'Postgres Internals',
114
+ truncated_content: 'I never quite understood autovacuum...',
115
+ },
116
+ ...
117
+ ],
118
+ }
119
+ ```
120
+
121
+ ## Getting Started
122
+
123
+ This section will explain the basic use of `Sequel::Packer`. Check out the [API
124
+ Reference](#api-reference) for an exhaustive coverage of the API and more
125
+ detailed documentation.
126
+
127
+ ### Installation
22
128
 
23
129
  Add this line to your application's Gemfile:
24
130
 
@@ -34,9 +140,9 @@ Or install it yourself as:
34
140
 
35
141
  $ gem install sequel-packer
36
142
 
37
- ## Usage
143
+ ### Example Schema
38
144
 
39
- Suppose we have the following basic database schema:
145
+ Most of the following examples will use the following database schema:
40
146
 
41
147
  ```ruby
42
148
  DB.create_table(:users) do
@@ -72,7 +178,7 @@ end
72
178
  ### Basic Fields
73
179
 
74
180
  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
181
+ After validating the user id, we end up with the Sequel dataset representing the
76
182
  data we want to return:
77
183
 
78
184
  ```ruby
@@ -97,7 +203,7 @@ end
97
203
  This can then be used as follows:
98
204
 
99
205
  ```ruby
100
- CommentPacker.new.pack(recent_comments)
206
+ CommentPacker.pack(recent_comments)
101
207
  => [
102
208
  {id: 536, content: "Great post, man!"},
103
209
  {id: 436, content: "lol"},
@@ -105,10 +211,10 @@ CommentPacker.new.pack(recent_comments)
105
211
  ]
106
212
  ```
107
213
 
108
- ### Packing associations by nesting Packers
214
+ ### Packing Associations by Nesting Packers
109
215
 
110
216
  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:
217
+ this by defining another packer for `Post` that uses the `CommentPacker`:
112
218
 
113
219
  ```ruby
114
220
  class PostPacker < Sequel::Packer
@@ -121,14 +227,15 @@ class PostPacker < Sequel::Packer
121
227
  end
122
228
  ```
123
229
 
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.
230
+ Since `post.comments` is an array of `Sequel::Models` and not a primitive value,
231
+ we must tell the Packer how to serialize them using another packer. The second
232
+ argument in `field :comments, CommentPacker` tells the `PostPacker` to use the
233
+ pack those comments using the `CommentPacker`.
127
234
 
128
235
  We can then use this as follows:
129
236
 
130
237
  ```ruby
131
- PostPacker.new.pack(Post.order(:id.desc).limit(1))
238
+ PostPacker.pack(Post[validated_id])
132
239
  => [
133
240
  {
134
241
  id: 682,
@@ -158,8 +265,8 @@ end
158
265
  ```
159
266
 
160
267
  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:
268
+ `PostPacker` instead, but then we'd have to redeclare all the other fields we
269
+ want on a packed `Comment`:
163
270
 
164
271
  ```ruby
165
272
  class CommentWithAuthorPacker < Sequel::Packer
@@ -184,7 +291,7 @@ end
184
291
  Declaring these fields in two places could cause them to get out of sync
185
292
  as more fields are added. Instead, we will use a _trait_. A _trait_ is a
186
293
  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:
294
+ of defining a totally new packer, we can extend the `CommentPacker` as follows:
188
295
 
189
296
  ```ruby
190
297
  class CommentPacker < Sequel::Packer
@@ -199,18 +306,18 @@ class CommentPacker < Sequel::Packer
199
306
  end
200
307
  ```
201
308
 
202
- To use a trait, simply pass it in when creating the packer instance:
309
+ To use a trait, simply pass it in when calling `pack`:
203
310
 
204
311
  ```ruby
205
312
  # Without the trait
206
- CommentPacker.new.pack(Comment.dataset)
313
+ CommentPacker.pack(Comment.dataset)
207
314
  => [
208
315
  {id: 536, content: "Great post, man!"},
209
316
  ...
210
317
  ]
211
318
 
212
319
  # With the trait
213
- CommentPacker.new(:author).pack(Comment.dataset)
320
+ CommentPacker.pack(Comment.dataset, :author)
214
321
  => [
215
322
  {
216
323
  id: 536,
@@ -249,13 +356,64 @@ Custom packers are written by creating subclasses of `Sequel::Packer`. This
249
356
  class defines a DSL for declaring how a Sequel Model will be converted into a
250
357
  plain Ruby hash.
251
358
 
252
- ### `self.model(sequel_model_class)`
359
+ ### Using a Packer
360
+
361
+ Using a Packer is dead simple. There's a single class method:
362
+
363
+ ```ruby
364
+ self.pack(data, *traits, **context)
365
+ ```
366
+
367
+ `data` can be in the form of a Sequel dataset, an array of Sequel models, or
368
+ a single Sequel model. No matter which form the data is passed in, the Packer
369
+ class will ensure nested data is efficiently loaded.
370
+
371
+ To pack additional fields defined in a trait, pass the name of the trait as an
372
+ additional argument, e.g., `UserPacker.pack(users, :recent_posts)` to include
373
+ recent posts with each user.
374
+
375
+ Finally, additional context can be provided to the Packer by passing additional
376
+ keyword arguments to `pack`. This context is handled opaquely by the Packer, but
377
+ it can be accessed in the blocks passed to `field` declarations. Common uses of
378
+ `context` include passing in the current user making a request, or passing in
379
+ additional precomputed data.
380
+
381
+ The implementation of `pack` is very simple. It creates an instance of a Packer,
382
+ by passing in the traits and the context, then calls `pack` on that instance,
383
+ and passes in the data:
384
+
385
+ ```ruby
386
+ def self.pack(data, *traits, **context)
387
+ return nil if !data # small easy optimization to avoid unnecessary work
388
+ new(*traits, **context).pack(data)
389
+ end
390
+ ```
391
+
392
+ It simply combines a constructor and single exposed instance method:
393
+
394
+ #### `initialize(*traits, **context)`
395
+
396
+ #### `pack(data)`
397
+
398
+ One instantiated, the same Packer could be used to pack data multiple times.
399
+ This is unlikely to be needed, but the functionality is there.
400
+
401
+ ### Defining a Packer
402
+
403
+ #### `self.model(sequel_model_class)`
253
404
 
254
405
  The beginning of each Packer class must begin with `model MySequelModel`, which
255
406
  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.
407
+ to catch certain errors at load time, rather than at run time:
257
408
 
258
- ### `self.field(column_name)` (or `self.field(method_name)`)
409
+ ```ruby
410
+ class UserPacker < Sequel::Packer
411
+ model User
412
+ ...
413
+ end
414
+ ```
415
+
416
+ #### `self.field(column_name)` (or `self.field(method_name)`)
259
417
 
260
418
  Defining the shape of the outputted data is done using the `field` method, which
261
419
  exists in four different variants. This first variant is the simplest. It simply
@@ -283,7 +441,7 @@ Then when `User.create(first_name: "Paul", last_name: "Martinez")` gets packed
283
441
  with `field :full_name` specified, the outputted hash will contain
284
442
  `full_name: "Paul Martinez"`.
285
443
 
286
- ### `self.field(key, &block)`
444
+ #### `self.field(key, &block)`
287
445
 
288
446
  A block can be passed to `field` to perform arbitrary computation and store the
289
447
  result under the specified `key`. The block will be passed the model as a single
@@ -309,7 +467,7 @@ class MyPacker < Sequel::Packer
309
467
  end
310
468
  ```
311
469
 
312
- ### `self.field(association, packer_class, *traits)`
470
+ #### `self.field(association, subpacker, *traits)`
313
471
 
314
472
  A Sequel association (defined in the model file using `one_to_many`, or
315
473
  `many_to_one`, etc.), can be packed using another Packer class, possibly with
@@ -317,15 +475,16 @@ multiple traits specified. A similar output could be generated by doing:
317
475
 
318
476
  ```ruby
319
477
  field :association do |model|
320
- packer_class.new(*traits).pack(model.association_dataset)
478
+ subpacker.pack(model.association_dataset, *traits)
321
479
  end
322
480
  ```
323
481
 
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.
482
+ This form is very inefficient though, because it would result in a new subpacker
483
+ getting instantiated for every packed model. Additionally, unless the subpacker
484
+ is declared up-front, the Packer won't know to eager load that association,
485
+ potentially resulting in many unnecessary database queries.
327
486
 
328
- ### `self.field(&block)`
487
+ #### `self.field(&block)`
329
488
 
330
489
  Passing a block but no `key` to `field` allows for arbitrary manipulation of the
331
490
  packed hash. The block will be passed the model and the partially packed hash.
@@ -338,7 +497,7 @@ field do |model, hash|
338
497
  end
339
498
  ```
340
499
 
341
- ### `self.trait(trait_name, &block)`
500
+ #### `self.trait(trait_name, &block)`
342
501
 
343
502
  Define optional serialization behavior by defining additional fields within a
344
503
  `trait` block. Traits can be opted into when initializing a packer by passing
@@ -347,15 +506,19 @@ the name of the trait as an argument:
347
506
  ```ruby
348
507
  class MyPacker < Sequel::Packer
349
508
  model MyObj
509
+ field :id
510
+
350
511
  trait :my_trait do
351
- field :my_optional_field
512
+ field :trait_field
352
513
  end
353
514
  end
354
515
 
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)
516
+ # packed objects don't have trait_field
517
+ MyPacker.pack(dataset)
518
+ => [{id: 1}, {id: 2}, ...]
519
+ # packed objects do have trait_field
520
+ MyPacker.pack(dataset, :my_trait)
521
+ => [{id: 1, trait_field: 'foo'}, {id: 2, trait_field: 'bar'}, ...]
359
522
  ```
360
523
 
361
524
  Traits can also be used when packing associations by passing the name of the
@@ -368,7 +531,7 @@ class MyOtherPacker < Sequel::Packer
368
531
  end
369
532
  ```
370
533
 
371
- ### `self.eager(*associations)`
534
+ #### `self.eager(*associations)`
372
535
 
373
536
  When packing an association, a Packer will automatically ensure that association
374
537
  is eager loaded, but there may be cases when an association will be accessed
@@ -392,7 +555,7 @@ class UserPacker < Sequel::Packer
392
555
  end
393
556
  end
394
557
 
395
- UserPacker.new.pack(User.dataset)
558
+ UserPacker.pack(User.dataset)
396
559
  => [
397
560
  {id: 123, num_posts: 7},
398
561
  {id: 456, num_posts: 3},
@@ -400,7 +563,7 @@ UserPacker.new.pack(User.dataset)
400
563
  ]
401
564
  ```
402
565
 
403
- This helps prevent N+1 query problems when not using Sequel's
566
+ Using `eager` can help prevent N+1 query problems when not using Sequel's
404
567
  [`TacticalEagerLoading`](https://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/TacticalEagerLoading.html)
405
568
  plugin.
406
569
 
@@ -421,22 +584,28 @@ class UserPacker < Sequel::Packer
421
584
  end
422
585
  ```
423
586
 
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`.
587
+ **IMPORTANT NOTE:** Eager procs are not guaranteed to be executed when passing
588
+ in models, rather than a dataset, to `pack`. Specifically, if the models already
589
+ have fetched the association, the Packer won't refetch it. Because of this, it's
590
+ good practice to use `set_association_packer` and `pack_association` (see next
591
+ section) in a `field` block and duplicate the filtering action.
592
+
593
+ Also keep in mind that this limits the association that gets used by ALL fields,
594
+ so if another field actually needs access to all the users posts, it might not
595
+ make sense to use `eager`.
427
596
 
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.
597
+ Additionally, it's important to note that if `eager` is called multiple times,
598
+ with multiple procs, each proc will get applied to the dataset, likely resulting
599
+ in overly restrictive filtering.
431
600
 
432
- ### `self.set_association_packer(association, packer_class, *traits)`
601
+ #### `self.set_association_packer(association, subpacker, *traits)`
433
602
 
434
603
  See `self.pack_association(association, models)` below.
435
604
 
436
- ### `self.pack_association(association, models)`
605
+ #### `self.pack_association(association, models)`
437
606
 
438
607
  The simplest way to pack an association is to use
439
- `self.field(association, packer_class, *traits)`, but sometimes this doesn't do
608
+ `self.field(association, subpacker, *traits)`, but sometimes this doesn't do
440
609
  exactly what we want. We may want to pack the association under a different key
441
610
  than the name of the association. Or we may only want to pack some of the
442
611
  associated models (and it may be difficult or impossible to express which subset
@@ -475,7 +644,7 @@ but if it is passed a single model, it will return just that packed model.
475
644
 
476
645
  Examples:
477
646
 
478
- #### Use a different field name than the name of the association
647
+ ##### Use a different field name than the name of the association
479
648
  ```ruby
480
649
  set_association_packer :ugly_internal_names, InternalPacker
481
650
  field :nice_external_names do |model|
@@ -483,7 +652,7 @@ field :nice_external_names do |model|
483
652
  end
484
653
  ```
485
654
 
486
- #### Pack a single instance of a `one_to_many` association
655
+ ##### Pack a single instance of a `one_to_many` association
487
656
  ```ruby
488
657
  class PostPacker < Sequel::Packer
489
658
  set_association_packer :comments, CommentPacker
@@ -493,7 +662,7 @@ class PostPacker < Sequel::Packer
493
662
  end
494
663
  ```
495
664
 
496
- ### `self.precompute(&block)`
665
+ #### `self.precompute(&block)`
497
666
 
498
667
  Occasionally packing a model may require a computation that doesn't fit in with
499
668
  the rest of the Packer paradigm. This may be a Sequel query that is particularly
@@ -532,40 +701,105 @@ class VideoUploadPacker < Sequel::Packer
532
701
  end
533
702
  ```
534
703
 
704
+ #### Instance method versions
535
705
 
536
- ### `initialize(*traits)`
706
+ In addition to the class method versions of `field`, `eager`,
707
+ `set_association_packer`, and `precompute`, there are also regular instance method
708
+ versions which take the exact same arguments. When writing a `trait` block, the
709
+ block is evaulated in the context of a new Packer instance and actually calls the
710
+ instance method versions instead.
537
711
 
538
- When creating an instance of a Packer class, pass in any traits desired to
539
- specify what additional data should be packed, if any.
712
+ ### Context
540
713
 
541
- ### `pack(dataset_or_models_or_model)`
714
+ In addition to the data to be packed, and a set of traits, the `pack` method
715
+ also accepts arbitrary keyword arguments. This is referred to as `context` is
716
+ handled opaquely by the Packer. The data passed in here is saved as the
717
+ `@context` instance variable, which is then accessible from within the blocks
718
+ passed to `field`, `trait`, and `precompute`, for whatever purpose. It is also
719
+ automatically passed to any nested subpackers.
542
720
 
543
- After creating a new instance of a Packer class, call `packer.pack` to tranform
544
- your data into packed Ruby hashes.
721
+ The most common usage for context would be to pass in the current user making
722
+ a request. It could then be used to pack permission levels about records, for
723
+ example.
545
724
 
546
- `pack` can accept a dataset, an array of models, or a single model. Even when
547
- passing models that have already been materialized, the Packer will make sure
548
- to eagerly load any nested associations needed for packing. When passed a
549
- dataset or an array of models, `pack` will return an array of hashes, and when
550
- passed just a single model, it will return a single hash.
725
+ ```ruby
726
+ class PostPacker < Sequel::Packer
727
+ model Post
551
728
 
552
- ## Development
729
+ eager :permissions
730
+ field :access_level do |post|
731
+ user_permission = post.permissions.find do |perm|
732
+ perm.user_id == @context[:user].id
733
+ end
553
734
 
554
- After checking out the repo, run `bin/setup` to install dependencies. Then, run
555
- `rake test` to run the tests. You can also run `bin/console` for an interactive
556
- prompt that will allow you to experiment.
735
+ user_permission.access_level
736
+ end
737
+ end
738
+ ```
557
739
 
558
- To install this gem onto your local machine, run `bundle exec rake install`. To
559
- release a new version, update the version number in `version.rb`, and then run
560
- `bundle exec rake release`, which will create a git tag for the version, push
561
- git commits and tags, and push the `.gem` file to
562
- [rubygems.org](https://rubygems.org).
740
+ You might notice something inefficient about the above code. Even though we only
741
+ want to look at the user's permission record, we fetch ALL of the permission
742
+ records for each Post. Ideally we would filter the `permissions` association
743
+ dataset when we call `eager`, but we don't have access to `@context` at that
744
+ point. This leads to the final DSL method available when writing a Packer:
745
+
746
+ #### `self.with_context(&block)`
747
+
748
+ You can pass a block to `with_context` that will be executed as soon as a Packer
749
+ instance is constructed. The block can access `@context` and can also call the
750
+ standard Packer DSL methods, `field`, `eager`, etc.
751
+
752
+ The above example could then be made more efficient as follows:
753
+
754
+ ```ruby
755
+ class PostPacker < Sequel::Packer
756
+ model Post
757
+
758
+ - eager :permissions
759
+ + with_context do
760
+ + eager permissions: (proc {|ds| ds.where(user_id: @context[:user].id)})
761
+ + end
762
+ end
763
+ ```
764
+
765
+ A very tricky usage of `with_context` (and not recommended...) would be to
766
+ control the traits used on subpackers:
767
+
768
+ ```ruby
769
+ class UserPacker < Sequel::Packer
770
+ model User
771
+
772
+ with_context do
773
+ field :comments, CommentPacker, *@context[:comment_traits]
774
+ end
775
+ end
776
+
777
+ UserPacker.pack(User.dataset, comment_traits: [])
778
+ => [{comments: [{id: 7}, ...]}]
779
+ UserPacker.pack(User.dataset, comment_traits: [:author])
780
+ => [{comments: [{id: 7, author: {id: 1, ...}}, ...]}]
781
+ UserPacker.pack(User.dataset, comment_traits: [:num_likes])
782
+ => [{comments: [{id: 7, likes: 53}, ...]}]
783
+ ```
563
784
 
564
785
  ## Contributing
565
786
 
566
787
  Bug reports and pull requests are welcome on GitHub at
567
788
  https://github.com/PaulJuliusMartinez/sequel-packer.
568
789
 
790
+ ### Development
791
+
792
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
793
+ `rake test` to run the tests. You can also run `bin/console` for an interactive
794
+ prompt that will allow you to experiment.
795
+
796
+ ### Releases
797
+
798
+ To release a new version, update the version number in
799
+ `lib/sequel/packer/version.rb`, update the `CHANGELOG.md` with new changes, then
800
+ run `rake release`, which which will create a git tag for the version, push git
801
+ commits and tags, and push the `.gem` file to
802
+ [rubygems.org](https://rubygems.org).
569
803
 
570
804
  ## License
571
805
 
data/lib/sequel/packer.rb CHANGED
@@ -9,15 +9,33 @@ module Sequel
9
9
  class AssociationDoesNotExistError < StandardError; end
10
10
  class InvalidAssociationPackerError < StandardError; end
11
11
  class UnknownTraitError < StandardError; end
12
+ class UnnecessaryWithContextError < StandardError; end
13
+ class NoAssociationSubpackerDefinedError < StandardError; end
12
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.
13
18
  def self.inherited(subclass)
14
- subclass.instance_variable_set(:@class_fields, [])
15
- subclass.instance_variable_set(:@class_traits, {})
16
- subclass.instance_variable_set(:@class_packers, {})
17
- subclass.instance_variable_set(:@class_eager_hash, nil)
18
- subclass.instance_variable_set(:@class_precomputations, [])
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
+ )
19
35
  end
20
36
 
37
+ # Declare the type of Sequel::Model this Packer will be used for. Used to
38
+ # validate associations at declaration time.
21
39
  def self.model(klass)
22
40
  if !(klass < Sequel::Model)
23
41
  fail(
@@ -35,18 +53,36 @@ module Sequel
35
53
  METHOD_FIELD = :method_field
36
54
  # field(:foo, &block)
37
55
  BLOCK_FIELD = :block_field
38
- # field(:association, packer_class)
56
+ # field(:association, subpacker)
39
57
  ASSOCIATION_FIELD = :association_field
40
58
  # field(&block)
41
59
  ARBITRARY_MODIFICATION_FIELD = :arbitrary_modification_field
42
60
 
43
- 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)
44
80
  Validation.check_field_arguments(
45
- @model, field_name, packer_class, traits, &block)
46
- 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)
47
83
 
48
84
  if field_type == ASSOCIATION_FIELD
49
- set_association_packer(field_name, packer_class, *traits)
85
+ set_association_packer(field_name, subpacker, *traits)
50
86
  end
51
87
 
52
88
  @class_fields << {
@@ -56,15 +92,10 @@ module Sequel
56
92
  }
57
93
  end
58
94
 
59
- def self.set_association_packer(association, packer_class, *traits)
60
- Validation.check_association_packer(
61
- @model, association, packer_class, traits)
62
- @class_packers[association] = [packer_class, traits]
63
- end
64
-
95
+ # Helper for determing a field type from the arguments to field.
65
96
  private_class_method def self.determine_field_type(
66
97
  field_name,
67
- packer_class,
98
+ subpacker,
68
99
  block
69
100
  )
70
101
  if block
@@ -74,7 +105,7 @@ module Sequel
74
105
  ARBITRARY_MODIFICATION_FIELD
75
106
  end
76
107
  else
77
- if packer_class
108
+ if subpacker
78
109
  ASSOCIATION_FIELD
79
110
  else
80
111
  METHOD_FIELD
@@ -82,6 +113,17 @@ module Sequel
82
113
  end
83
114
  end
84
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.
85
127
  def self.trait(name, &block)
86
128
  if @class_traits.key?(name)
87
129
  raise ArgumentError, "Trait :#{name} already defined"
@@ -92,6 +134,14 @@ module Sequel
92
134
  @class_traits[name] = block
93
135
  end
94
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.
95
145
  def self.eager(*associations)
96
146
  @class_eager_hash = EagerHash.merge!(
97
147
  @class_eager_hash,
@@ -99,6 +149,12 @@ module Sequel
99
149
  )
100
150
  end
101
151
 
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.
102
158
  def self.precompute(&block)
103
159
  if !block
104
160
  raise ArgumentError, 'Sequel::Packer.precompute must be passed a block'
@@ -106,25 +162,55 @@ module Sequel
106
162
  @class_precomputations << block
107
163
  end
108
164
 
109
- def initialize(*traits)
110
- @subpackers = nil
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
111
175
 
112
- # If there aren't any traits, we can just re-use the class variables.
113
- if traits.empty?
114
- @instance_fields = class_fields
115
- @instance_packers = class_packers
116
- @instance_eager_hash = class_eager_hash
117
- @instance_precomputations = class_precomputations
118
- else
119
- @instance_fields = class_fields.dup
120
- @instance_packers = class_packers.dup
121
- @instance_eager_hash = EagerHash.deep_dup(class_eager_hash)
122
- @instance_precomputations = class_precomputations.dup
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)
123
208
  end
124
209
 
125
210
  # Evaluate trait blocks, which might add new fields to @instance_fields,
126
- # new packers to @instance_packers, and/or new associations to
127
- # @instance_eager_hash.
211
+ # new packers to @instance_packers, new associations to
212
+ # @instance_eager_hash, and/or new precomputations to
213
+ # @instance_precomputations.
128
214
  traits.each do |trait|
129
215
  trait_block = class_traits[trait]
130
216
  if !trait_block
@@ -135,10 +221,9 @@ module Sequel
135
221
  end
136
222
 
137
223
  # Create all the subpackers, and merge in their eager hashes.
138
- @instance_packers.each do |association, (packer_class, traits)|
139
- association_packer = packer_class.new(*traits)
224
+ @instance_packers.each do |association, (subpacker, traits)|
225
+ association_packer = subpacker.new(*traits, @context)
140
226
 
141
- @subpackers ||= {}
142
227
  @subpackers[association] = association_packer
143
228
 
144
229
  @instance_eager_hash = EagerHash.merge!(
@@ -148,38 +233,38 @@ module Sequel
148
233
  end
149
234
  end
150
235
 
151
- def pack(to_be_packed)
152
- case to_be_packed
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
153
248
  when Sequel::Dataset
154
- if @instance_eager_hash
155
- to_be_packed = to_be_packed.eager(@instance_eager_hash)
156
- end
157
- models = to_be_packed.all
249
+ data = data.eager(@instance_eager_hash) if @instance_eager_hash
250
+ models = data.all
158
251
 
159
252
  run_precomputations(models)
160
253
  pack_models(models)
161
254
  when Sequel::Model
162
255
  if @instance_eager_hash
163
- EagerLoading.eager_load(
164
- class_model,
165
- [to_be_packed],
166
- @instance_eager_hash
167
- )
256
+ EagerLoading.eager_load(class_model, [data], @instance_eager_hash)
168
257
  end
169
258
 
170
- run_precomputations([to_be_packed])
171
- pack_model(to_be_packed)
259
+ run_precomputations([data])
260
+ pack_model(data)
172
261
  when Array
173
262
  if @instance_eager_hash
174
- EagerLoading.eager_load(
175
- class_model,
176
- to_be_packed,
177
- @instance_eager_hash
178
- )
263
+ EagerLoading.eager_load(class_model, data, @instance_eager_hash)
179
264
  end
180
265
 
181
- run_precomputations(to_be_packed)
182
- pack_models(to_be_packed)
266
+ run_precomputations(data)
267
+ pack_models(data)
183
268
  when NilClass
184
269
  nil
185
270
  end
@@ -187,6 +272,8 @@ module Sequel
187
272
 
188
273
  private
189
274
 
275
+ # Run any blocks declared using precompute on the given models, as well as
276
+ # any precompute blocks declared by subpackers.
190
277
  def run_precomputations(models)
191
278
  @instance_packers.each do |association, _|
192
279
  subpacker = @subpackers[association]
@@ -208,12 +295,15 @@ module Sequel
208
295
  end
209
296
  end
210
297
 
298
+ # Check if a Packer has any precompute blocks declared, to avoid the
299
+ # overhead of flattening the child associations.
211
300
  def has_precomputations?
212
301
  return true if @instance_precomputations.any?
213
302
  return false if !@subpackers
214
303
  @subpackers.values.any? {|sp| sp.send(:has_precomputations?)}
215
304
  end
216
305
 
306
+ # Pack a single model by processing all of the Packer's declared fields.
217
307
  def pack_model(model)
218
308
  h = {}
219
309
 
@@ -236,15 +326,25 @@ module Sequel
236
326
  h
237
327
  end
238
328
 
329
+ # Pack an array of models by processing all of the Packer's declared fields.
239
330
  def pack_models(models)
240
331
  models.map {|m| pack_model(m)}
241
332
  end
242
333
 
334
+ # Pack models from an association using the designated subpacker.
243
335
  def pack_association(association, associated_models)
244
336
  return nil if !associated_models
245
337
 
246
338
  packer = @subpackers[association]
247
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
+
248
348
  if associated_models.is_a?(Array)
249
349
  packer.send(:pack_models, associated_models)
250
350
  else
@@ -252,16 +352,19 @@ module Sequel
252
352
  end
253
353
  end
254
354
 
255
- 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)
256
359
  klass = self.class
257
360
 
258
361
  Validation.check_field_arguments(
259
- class_model, field_name, packer_class, traits, &block)
362
+ class_model, field_name, subpacker, traits, &block)
260
363
  field_type =
261
- klass.send(:determine_field_type, field_name, packer_class, block)
364
+ klass.send(:determine_field_type, field_name, subpacker, block)
262
365
 
263
366
  if field_type == ASSOCIATION_FIELD
264
- set_association_packer(field_name, packer_class, *traits)
367
+ set_association_packer(field_name, subpacker, *traits)
265
368
  end
266
369
 
267
370
  @instance_fields << {
@@ -271,13 +374,19 @@ module Sequel
271
374
  }
272
375
  end
273
376
 
274
- def set_association_packer(association, packer_class, *traits)
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)
275
381
  Validation.check_association_packer(
276
- class_model, association, packer_class, traits)
382
+ class_model, association, subpacker, traits)
277
383
 
278
- @instance_packers[association] = [packer_class, traits]
384
+ @instance_packers[association] = [subpacker, traits]
279
385
  end
280
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.
281
390
  def eager(*associations)
282
391
  @instance_eager_hash = EagerHash.merge!(
283
392
  @instance_eager_hash,
@@ -285,6 +394,9 @@ module Sequel
285
394
  )
286
395
  end
287
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.
288
400
  def precompute(&block)
289
401
  if !block
290
402
  raise ArgumentError, 'Sequel::Packer.precompute must be passed a block'
@@ -292,10 +404,25 @@ module Sequel
292
404
  @instance_precomputations << block
293
405
  end
294
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.
295
419
  def eager_hash
296
420
  @instance_eager_hash
297
421
  end
298
422
 
423
+ # The following methods expose the class instance variables containing the
424
+ # core definition of the Packer.
425
+
299
426
  def class_model
300
427
  self.class.instance_variable_get(:@model)
301
428
  end
@@ -319,6 +446,10 @@ module Sequel
319
446
  def class_precomputations
320
447
  self.class.instance_variable_get(:@class_precomputations)
321
448
  end
449
+
450
+ def class_with_contexts
451
+ self.class.instance_variable_get(:@class_with_contexts)
452
+ end
322
453
  end
323
454
  end
324
455
 
@@ -1,5 +1,5 @@
1
1
  module Sequel
2
2
  class Packer
3
- VERSION = "0.4.0"
3
+ VERSION = "0.5.0"
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.4.0
4
+ version: 0.5.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-17 00:00:00.000000000 Z
11
+ date: 2020-05-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sequel