sequel-packer 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd29eb07b27fb23e4f4ccf1e9cefc38c608ffea0bc6b6a1757cd240cdb5c58cd
4
- data.tar.gz: b2581d4f789fa3db36a80bcf2b11918ec5ce88d4249901e345b72f5a17ff4f28
3
+ metadata.gz: 248126ba630e1d763f32e911c992b296cd32b5201503a67296bbb18fa4000679
4
+ data.tar.gz: abecaa06c7e4d64eef3e4b91be2311cd2921e966062fd03fc5a5d590604c6b92
5
5
  SHA512:
6
- metadata.gz: 06edeac19dd43963ae1711d58f4f77a49e914841a4e108fae027de088263c07609b90cf2fec5cd6284b1fa999d71fb38bd452922fd11654b27097ea4ad8c71a0
7
- data.tar.gz: 388fae9fad1bed49c71697be1f862be334ee4d27229f15a24f1d177fe5e8e36887b8fbfffdefbc64a3d526bb71fb5b79bc4b9d503fac3c979247006f82075d84
6
+ metadata.gz: 0231fbedf7d8d7ccd77093e00617bf15688ed57e52d169876780854023e3c6c758dd34d002d89952795291a8c1f5c7ef398f7f24543cf686be85e496059a5f58
7
+ data.tar.gz: da6163737a56d4a96205ff0c26ce9ece0b90b2763c1fe271b899d3b55261bc67cf36ea35d4e75cdac540c81fd587618fbb0272691bbd86fcca0375e712d31809
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ### 0.0.2 (2020-05-11)
2
+
3
+ * Added support for `Sequel::Packer.field(key, &block)` and
4
+ `Sequel::Packer.field(&block)`
5
+ * Added validation to `Sequel::Packer::field` to detect incorrect usage.
6
+ * Added support for nested packing of associations using
7
+ `Sequel::Packer.field(association, packer_class)`
8
+ * Update README with usage instructions and basic API reference.
9
+
1
10
  ### 0.0.1 (2020-05-10)
2
11
 
3
12
  * Most basic functionality for serializing single fields
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Sequel::Packer
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/sequel/packer`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ A Ruby serialization library to be used with the Sequel ORM.
6
4
 
7
5
  ## Installation
8
6
 
@@ -22,19 +20,232 @@ Or install it yourself as:
22
20
 
23
21
  ## Usage
24
22
 
25
- TODO: Write usage instructions here
23
+ Suppose we have the following basic database schema:
24
+
25
+ ```ruby
26
+ DB.create_table(:users) do
27
+ primary_key :id
28
+ String :name
29
+ end
30
+
31
+ DB.create_table(:posts) do
32
+ primary_key :id
33
+ foreign_key :author_id, :users
34
+ String :title
35
+ String :content
36
+ end
37
+
38
+ DB.create_table(:comments) do
39
+ primary_key :id
40
+ foreign_key :author_id, :users
41
+ foreign_key :post_id, :posts
42
+ String :content
43
+ end
44
+
45
+ class User < Sequel::Model(:users); end
46
+ class Post < Sequel::Model(:posts); end
47
+ class Comment < Sequel::Model(:comments)
48
+ many_to_one :author, key: :author_id, class: :User
49
+ end
50
+ ```
51
+
52
+ Suppose an endpoint wants to fetch all the ten most recent comments by a user.
53
+ After validating the user id, we end up with the Sequel dataset represting the
54
+ data we want to return:
55
+
56
+ ```ruby
57
+ recent_comments = Comment
58
+ .where(author_id: user_id)
59
+ .order(:id.desc)
60
+ .limit(10)
61
+ ```
62
+
63
+ We can define a Packer class to serialize just fields we want to, using a
64
+ custom DSL:
65
+
66
+ ```ruby
67
+ class CommentPacker < Sequel::Packer
68
+ model Comment
69
+
70
+ field :id
71
+ field :content
72
+ end
73
+ ```
74
+
75
+ This can then be used as follows:
76
+
77
+ ```ruby
78
+ CommentPacker.new.pack(recent_comments)
79
+ => [
80
+ {id: 536, "Great post, man!"},
81
+ {id: 436, "lol"},
82
+ {id: 413, "What a story..."},
83
+ ]
84
+ ```
85
+
86
+ In another context, suppose that we want to fetch the comments on a post along
87
+ with the authors of those comments.
88
+
89
+ In this case we could define two separate Packers, one for Users, and another
90
+ for Comments:
91
+
92
+ ```ruby
93
+ class UserPacker < Sequel::Packer
94
+ model User
95
+
96
+ field :id
97
+ field :name
98
+ end
99
+
100
+ class CommentWithAuthorPacker < Sequel::Packer
101
+ model Comment
102
+
103
+ field :id
104
+ field :content
105
+ field :author, UserPacker
106
+ end
107
+ ```
108
+
109
+ When we use this, we get:
110
+
111
+ ```ruby
112
+ comments_on_post = Comment.where(post_id: post.id)
113
+ CommentWithAuthorPacker.new.pack(comments_on_post)
114
+ => [
115
+ {
116
+ id: 752,
117
+ content: "Great gem",
118
+ author: {id: 382, name: "Jeremy Evans"},
119
+ },
120
+ {
121
+ id: 162,
122
+ content: "Never seen anything like it",
123
+ author: {id: 382, name: "Donald Knuth"},
124
+ },
125
+ ...
126
+ ]
127
+ ```
128
+
129
+ See the API Reference below for the exact API.
130
+
131
+ ## API Reference
132
+
133
+ Custom packers are written by creating subclasses of `Sequel::Packer`. This
134
+ class defines a DSL for declaring how a Sequel Model will be converted into a
135
+ plain Ruby hash.
136
+
137
+ ### `self.model(sequel_model_class)`
138
+
139
+ The beginning of each Packer class must begin with `model MySequelModel`, which
140
+ specifies which Sequel Model this Packer class will serialize. This is mostly
141
+ to catch certain errors at load time, rather than at run time.
142
+
143
+ ### `self.field(column_name)` (or `self.field(method_name)`)
144
+
145
+ Defining the shape of the outputted data is done using the `field` method, which
146
+ exists in four different variants. This first variant is the simplest. It simply
147
+ fetches the value of the column from the model and stores it in the outputted
148
+ hash under a key of the same name. Essentially `field :my_column` eventually
149
+ results in `hash[:my_column] = model.my_column`.
150
+
151
+ Sequel Models define accessor methods for each column in the underlying table,
152
+ so technically underneath the hood Packer is actually calling the sending the
153
+ method `column_name` to the model: `hash[:my_column] = model.send(:my_column)`.
154
+
155
+ This means that the result of any method can be serialized using
156
+ `field :method_name`. For example, suppose a User model has a `first_name` and
157
+ `last_name` column, and a helper method `full_name`:
158
+
159
+ ```ruby
160
+ class User < Sequel::Model(:users)
161
+ def full_name
162
+ "#{first_name} #{last_name}"
163
+ end
164
+ end
165
+ ```
166
+
167
+ Then when `User.create(first_name: "Paul", last_name: "Martinez")` gets packed
168
+ with `field :full_name` specified, the outputted hash will contain
169
+ `full_name: "Paul Martinez"`.
170
+
171
+ ### `self.field(key, &block)`
172
+
173
+ A block can be passed to `field` to perform arbitrary computation and store the
174
+ result under the specified `key`. The block will be passed the model as a single
175
+ argument. Use this to call methods on the model that may take additional
176
+ arguments, or to "rename" a column.
177
+
178
+ Examples:
179
+
180
+ ```ruby
181
+ class MyPacker < Sequel::Packer
182
+ model MyModel
183
+
184
+ field :friendly_public_name do |model|
185
+ model.unfriendly_internal_name
186
+ end
187
+
188
+ # Shorthand for above
189
+ field :friendly_public_name, &:unfriendly_internal_name
190
+
191
+ field :foo do |model|
192
+ model.bar(baz, quux)
193
+ end
194
+ end
195
+ ```
196
+
197
+ ### `self.field(association, packer_class)
198
+
199
+ A Sequel association (defined in the model file using `one_to_many`, or
200
+ `many_to_one`, etc.), can be packed using another Packer class. A similar
201
+ output could be generated by doing:
202
+
203
+ ```ruby
204
+ field :association do |model|
205
+ packer_class.new.pack(model.association_dataset)
206
+ end
207
+ ```
208
+
209
+ Though this version of course would result in many more queries to the database,
210
+ which are not required when using the shorthand form.
211
+
212
+ ### `self.field(&block)`
213
+
214
+ Passing a block but no `key` to `field` allows for arbitrary manipulation of the
215
+ packed hash. The block will be passed the model and the partially packed hash.
216
+ One potential usage is for dynamic keys that cannot be determined at load time,
217
+ but otherwise it's meant as a general escape hatch.
218
+
219
+ ```ruby
220
+ field do |model, hash|
221
+ hash[model.compute_dynamic_key] = model.dynamic_value
222
+ end
223
+ ```
224
+
225
+ ### `pack(dataset)`
226
+
227
+ After creating a new instance of a Packer class, call `packer.pack(dataset)` to
228
+ materialize a dataset and convert it to an array of packed Ruby hashes.
26
229
 
27
230
  ## Development
28
231
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
232
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
233
+ `rake test` to run the tests. You can also run `bin/console` for an interactive
234
+ prompt that will allow you to experiment.
30
235
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
236
+ To install this gem onto your local machine, run `bundle exec rake install`. To
237
+ release a new version, update the version number in `version.rb`, and then run
238
+ `bundle exec rake release`, which will create a git tag for the version, push
239
+ git commits and tags, and push the `.gem` file to
240
+ [rubygems.org](https://rubygems.org).
32
241
 
33
242
  ## Contributing
34
243
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/sequel-packer.
244
+ Bug reports and pull requests are welcome on GitHub at
245
+ https://github.com/PaulJuliusMartinez/sequel-packer.
36
246
 
37
247
 
38
248
  ## License
39
249
 
40
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
250
+ The gem is available as open source under the terms of the
251
+ [MIT License](https://opensource.org/licenses/MIT).
@@ -1,5 +1,5 @@
1
1
  module Sequel
2
2
  class Packer
3
- VERSION = "0.0.1"
3
+ VERSION = "0.0.2"
4
4
  end
5
5
  end
data/lib/sequel/packer.rb CHANGED
@@ -1,25 +1,216 @@
1
1
  module Sequel
2
2
  class Packer
3
+ # For invalid arguments provided to the field class method.
4
+ class FieldArgumentError < ArgumentError; end
5
+ # Must declare a model with `model MyModel` before calling field.
6
+ class ModelNotYetDeclaredError < StandardError; end
7
+
3
8
  def self.inherited(subclass)
4
9
  subclass.instance_variable_set(:@fields, [])
5
10
  end
6
11
 
7
- def self.field(field_name)
8
- @fields << field_name
12
+ def self.model(klass)
13
+ if !(klass < Sequel::Model)
14
+ fail(
15
+ ArgumentError,
16
+ 'model declaration must be a subclass of Sequel::Model',
17
+ )
18
+ end
19
+
20
+ fail ArgumentError, 'model already declared' if @model
21
+
22
+ @model = klass
9
23
  end
10
24
 
11
- def fields
12
- self.class.instance_variable_get(:@fields)
25
+ # field(:foo)
26
+ METHOD_FIELD = :method_field
27
+ # field(:foo, &block)
28
+ BLOCK_FIELD = :block_field
29
+ # field(:association, packer_class)
30
+ ASSOCIATION_FIELD = :association_field
31
+ # field(&block)
32
+ ARBITRARY_MODIFICATION_FIELD = :arbitrary_modification_field
33
+
34
+ def self.field(field_name=nil, packer_class=nil, &block)
35
+ fail ModelNotYetDeclaredError if !@model
36
+
37
+ # This check applies to all invocations:
38
+ if field_name && !field_name.is_a?(Symbol) && !field_name.is_a?(String)
39
+ raise(
40
+ FieldArgumentError,
41
+ 'Field name passed to Sequel::Packer::field must be a Symbol or ' +
42
+ 'a String.',
43
+ )
44
+ end
45
+
46
+ if block
47
+ # If the user passed a block, we'll assume they either want:
48
+ # field :foo {|model| ...}
49
+ # or field {|model, hash| ...}
50
+ #
51
+ if packer_class
52
+ raise(
53
+ FieldArgumentError,
54
+ 'When passing a block to Sequel::Packer::field, either pass the ' +
55
+ 'name of field as a single argument (e.g., field(:foo) ' +
56
+ '{|model| ...}), or nothing at all to perform arbitrary ' +
57
+ 'modifications of the final hash (e.g., field {|model, hash| ' +
58
+ '...}).',
59
+ )
60
+ end
61
+
62
+ arity = block.arity
63
+
64
+ # When using Symbol.to_proc (field(:foo, &:calculate_foo)), the block has arity -1.
65
+ if field_name && arity != 1 && arity != -1
66
+ raise(
67
+ FieldArgumentError,
68
+ "The block used to define :#{field_name} must accept exactly " +
69
+ 'one argument.',
70
+ )
71
+ end
72
+
73
+ if !field_name && arity != 2
74
+ raise(
75
+ FieldArgumentError,
76
+ 'When passing an arbitrary block to Sequel::Packer::field, the ' +
77
+ 'block must accept exactly two arguments: the model and the ' +
78
+ 'partially packed hash.',
79
+ )
80
+ end
81
+ else
82
+ # In this part of the if, block is not defined
83
+
84
+ if !field_name
85
+ # Note that this error isn't technically true, but usage of the
86
+ # field {|model, hash| ...} variant is likely pretty rare.
87
+ raise(
88
+ FieldArgumentError,
89
+ 'Must pass a field name to Sequel::Packer::field.',
90
+ )
91
+ end
92
+
93
+ if packer_class
94
+ if !@model.associations.include?(field_name)
95
+ raise(
96
+ FieldArgumentError,
97
+ 'Passing multiple arguments to Sequel::Packer::field ' +
98
+ 'is used to serialize associations with designated ' +
99
+ "packers, but the association #{field_name} does not " +
100
+ "exist on #{@model}.",
101
+ )
102
+ end
103
+
104
+ if !(packer_class < Sequel::Packer)
105
+ raise(
106
+ FieldArgumentError,
107
+ 'When declaring the serialization behavior for an ' +
108
+ 'association, the second argument must be a Sequel::Packer ' +
109
+ "subclass. #{packer_class} is not a subclass of " +
110
+ 'Sequel::Packer.',
111
+ )
112
+ end
113
+
114
+ association_model =
115
+ @model.association_reflections[field_name].associated_class
116
+ packer_class_model = packer_class.instance_variable_get(:@model)
117
+
118
+ if !(association_model <= packer_class_model)
119
+ raise(
120
+ FieldArgumentError,
121
+ "Model for association packer (#{packer_class_model}) " +
122
+ "doesn't match model for the #{field_name} association " +
123
+ "(#{association_model})",
124
+ )
125
+ end
126
+ else
127
+ if @model.associations.include?(field_name)
128
+ raise(
129
+ FieldArgumentError,
130
+ 'When declaring a field for a model association, you must ' +
131
+ 'also pass a Sequel::Packer class to use as the second ' +
132
+ 'argument to field.',
133
+ )
134
+ end
135
+ end
136
+ end
137
+
138
+ field_type =
139
+ if block
140
+ if field_name
141
+ BLOCK_FIELD
142
+ else
143
+ ARBITRARY_MODIFICATION_FIELD
144
+ end
145
+ else
146
+ if packer_class
147
+ ASSOCIATION_FIELD
148
+ else
149
+ METHOD_FIELD
150
+ end
151
+ end
152
+
153
+ @fields << {
154
+ type: field_type,
155
+ name: field_name,
156
+ packer: packer_class,
157
+ block: block,
158
+ }
159
+ end
160
+
161
+ def initialize
162
+ @packers = nil
163
+
164
+ fields.each do |field_options|
165
+ if field_options[:type] == ASSOCIATION_FIELD
166
+ @packers ||= {}
167
+ @packers[field_options[:name]] = field_options[:packer].new
168
+ end
169
+ end
13
170
  end
14
171
 
15
172
  def pack(dataset)
16
- dataset.map do |model|
17
- h = {}
18
- fields.each do |field_name|
173
+ models = dataset.all
174
+ pack_models(models)
175
+ end
176
+
177
+ def pack_model(model)
178
+ h = {}
179
+
180
+ fields.each do |field_options|
181
+ field_name = field_options[:name]
182
+
183
+ case field_options[:type]
184
+ when METHOD_FIELD
19
185
  h[field_name] = model.send(field_name)
186
+ when BLOCK_FIELD
187
+ h[field_name] = field_options[:block].call(model)
188
+ when ASSOCIATION_FIELD
189
+ associated_objects = model.send(field_name)
190
+
191
+ if !associated_objects
192
+ h[field_name] = nil
193
+ elsif associated_objects.is_a?(Array)
194
+ h[field_name] = @packers[field_name].pack_models(associated_objects)
195
+ else
196
+ @packers[field_name].pack_model(associated_objects)
197
+ end
198
+ when ARBITRARY_MODIFICATION_FIELD
199
+ field_options[:block].call(model, h)
20
200
  end
21
- h
22
201
  end
202
+
203
+ h
204
+ end
205
+
206
+ def pack_models(models)
207
+ models.map {|m| pack_model(m)}
208
+ end
209
+
210
+ private
211
+
212
+ def fields
213
+ self.class.instance_variable_get(:@fields)
23
214
  end
24
215
  end
25
216
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequel-packer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Julius Martinez