sequel-packer 0.0.1 → 0.0.2

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