serega 0.41.0 → 0.50.0

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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +123 -1205
  3. data/VERSION +1 -1
  4. data/lib/serega/attribute.rb +186 -131
  5. data/lib/serega/attribute_normalizer.rb +164 -345
  6. data/lib/serega/attribute_normalizers/batch.rb +45 -0
  7. data/lib/serega/attribute_normalizers/block.rb +81 -0
  8. data/lib/serega/attribute_normalizers/delegate.rb +34 -0
  9. data/lib/serega/attribute_normalizers/hash_access.rb +37 -0
  10. data/lib/serega/attribute_normalizers/preloads.rb +46 -0
  11. data/lib/serega/attribute_value_resolvers/batch.rb +5 -54
  12. data/lib/serega/attribute_value_resolvers/const.rb +2 -24
  13. data/lib/serega/attribute_value_resolvers/delegate.rb +17 -28
  14. data/lib/serega/attribute_value_resolvers/hash_access.rb +34 -52
  15. data/lib/serega/attribute_value_resolvers/keyword.rb +10 -25
  16. data/lib/serega/attribute_values.rb +46 -0
  17. data/lib/serega/batch_loader.rb +37 -0
  18. data/lib/serega/conditions.rb +56 -0
  19. data/lib/serega/config.rb +106 -145
  20. data/lib/serega/engine/pulls.rb +64 -0
  21. data/lib/serega/engine/run.rb +106 -0
  22. data/lib/serega/errors.rb +36 -8
  23. data/lib/serega/formatter.rb +98 -0
  24. data/lib/serega/plan/cache.rb +96 -0
  25. data/lib/serega/plan/per_ractor_cache.rb +23 -0
  26. data/lib/serega/plan.rb +101 -128
  27. data/lib/serega/plan_point.rb +44 -145
  28. data/lib/serega/plugins/activerecord_preloads/activerecord_preloads.rb +14 -100
  29. data/lib/serega/plugins/activerecord_preloads/lib/preloader.rb +8 -75
  30. data/lib/serega/plugins/context_metadata/context_metadata.rb +40 -123
  31. data/lib/serega/plugins/metadata/check_params.rb +80 -0
  32. data/lib/serega/plugins/metadata/meta_attribute.rb +40 -79
  33. data/lib/serega/plugins/metadata/metadata.rb +40 -168
  34. data/lib/serega/plugins/root/root.rb +91 -235
  35. data/lib/serega/plugins.rb +16 -49
  36. data/lib/serega/presenter.rb +108 -0
  37. data/lib/serega/result_builder.rb +68 -0
  38. data/lib/serega/result_code.rb +203 -0
  39. data/lib/serega/shareable.rb +52 -0
  40. data/lib/serega/source_group.rb +187 -0
  41. data/lib/serega/utils/collection_detector.rb +19 -13
  42. data/lib/serega/utils/deep_merge.rb +26 -0
  43. data/lib/serega/utils/enum_deep_dup.rb +10 -16
  44. data/lib/serega/utils/enum_deep_freeze.rb +8 -14
  45. data/lib/serega/utils/method_signature.rb +6 -15
  46. data/lib/serega/utils/parse_string_modifiers.rb +63 -0
  47. data/lib/serega/utils/ractor_local.rb +36 -0
  48. data/lib/serega/utils/serialized_attribute_error.rb +60 -20
  49. data/lib/serega/utils/to_hash.rb +5 -26
  50. data/lib/serega/validations/attribute/check_block.rb +3 -15
  51. data/lib/serega/validations/attribute/check_name.rb +4 -15
  52. data/lib/serega/validations/attribute/check_opt_base_serializer.rb +3 -15
  53. data/lib/serega/validations/attribute/check_opt_batch.rb +26 -56
  54. data/lib/serega/validations/attribute/check_opt_condition.rb +55 -0
  55. data/lib/serega/validations/attribute/check_opt_delegate.rb +10 -63
  56. data/lib/serega/validations/attribute/check_opt_format.rb +36 -0
  57. data/lib/serega/validations/attribute/check_opt_hash_access.rb +11 -59
  58. data/lib/serega/validations/attribute/check_opt_hide.rb +3 -14
  59. data/lib/serega/validations/attribute/check_opt_many.rb +18 -20
  60. data/lib/serega/validations/attribute/check_opt_method.rb +3 -25
  61. data/lib/serega/validations/attribute/check_opt_preload.rb +3 -26
  62. data/lib/serega/validations/attribute/check_opt_serializer.rb +4 -18
  63. data/lib/serega/validations/attribute/check_opt_value.rb +3 -21
  64. data/lib/serega/validations/check_attribute_params.rb +92 -71
  65. data/lib/serega/validations/check_batch_loader_params.rb +43 -62
  66. data/lib/serega/validations/check_formatter.rb +80 -0
  67. data/lib/serega/validations/check_serialize_keywords.rb +23 -0
  68. data/lib/serega/validations/initiate/check_modifiers.rb +14 -20
  69. data/lib/serega/validations/utils/check_allowed_keys.rb +3 -13
  70. data/lib/serega/validations/utils/check_hash_access_value.rb +44 -0
  71. data/lib/serega/validations/utils/check_opt_is_bool.rb +3 -13
  72. data/lib/serega/validations/utils/check_opt_is_hash.rb +3 -13
  73. data/lib/serega/validations/utils/check_opt_is_string_or_symbol.rb +3 -13
  74. data/lib/serega/version.rb +1 -4
  75. data/lib/serega.rb +480 -372
  76. data/sig/serega/plugins.rbs +49 -0
  77. data/sig/serega.rbs +142 -0
  78. metadata +43 -39
  79. data/lib/serega/data_builder.rb +0 -54
  80. data/lib/serega/engine/level.rb +0 -59
  81. data/lib/serega/engine/level_queue.rb +0 -56
  82. data/lib/serega/engine/loader.rb +0 -70
  83. data/lib/serega/helpers/serializer_class_helper.rb +0 -19
  84. data/lib/serega/object_serializer.rb +0 -118
  85. data/lib/serega/plugins/camel_case/camel_case.rb +0 -213
  86. data/lib/serega/plugins/depth_limit/depth_limit.rb +0 -206
  87. data/lib/serega/plugins/explicit_many_option/explicit_many_option.rb +0 -69
  88. data/lib/serega/plugins/explicit_many_option/validations/check_opt_many.rb +0 -38
  89. data/lib/serega/plugins/formatters/formatters.rb +0 -322
  90. data/lib/serega/plugins/if/if.rb +0 -287
  91. data/lib/serega/plugins/if/validations/check_opt_if.rb +0 -72
  92. data/lib/serega/plugins/if/validations/check_opt_if_value.rb +0 -77
  93. data/lib/serega/plugins/if/validations/check_opt_unless.rb +0 -72
  94. data/lib/serega/plugins/if/validations/check_opt_unless_value.rb +0 -77
  95. data/lib/serega/plugins/metadata/validations/check_block.rb +0 -47
  96. data/lib/serega/plugins/metadata/validations/check_opt_const.rb +0 -39
  97. data/lib/serega/plugins/metadata/validations/check_opt_hide_empty.rb +0 -35
  98. data/lib/serega/plugins/metadata/validations/check_opt_hide_nil.rb +0 -35
  99. data/lib/serega/plugins/metadata/validations/check_opt_value.rb +0 -61
  100. data/lib/serega/plugins/metadata/validations/check_opts.rb +0 -59
  101. data/lib/serega/plugins/metadata/validations/check_path.rb +0 -50
  102. data/lib/serega/plugins/presenter/presenter.rb +0 -242
  103. data/lib/serega/plugins/string_modifiers/parse_string_modifiers.rb +0 -111
  104. data/lib/serega/plugins/string_modifiers/string_modifiers.rb +0 -63
  105. data/lib/serega/utils/symbol_name.rb +0 -39
  106. data/lib/serega/validations/attribute/check_opt_const.rb +0 -40
  107. data/lib/serega/validations/check_initiate_params.rb +0 -57
  108. data/lib/serega/validations/check_serialize_params.rb +0 -54
data/README.md CHANGED
@@ -1,1306 +1,224 @@
1
- # Serega Ruby Serializer
1
+ # Serega
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/serega.svg)](https://badge.fury.io/rb/serega)
4
- [![GitHub Actions][build-badge]][build]
5
-
6
- The Serega Ruby Serializer provides easy and powerful DSL to describe your
7
- objects and serialize them to Hash, ready for further conversion to JSON or
8
- other formats.
9
-
10
- ---
11
-
12
- 📌 Serega does not depend on any gem and works with any framework
13
-
14
- ---
15
-
16
- It has some great features:
3
+ <!-- TODO: logo (SVG, light and dark variants)
4
+ <p align="center">
5
+ <picture>
6
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
7
+ <img src="assets/logo.svg" alt="Serega" width="240">
8
+ </picture>
9
+ </p>
10
+ -->
17
11
 
18
- - Manually [select serialized fields](#selecting-fields)
19
- - Secure from malicious queries with [depth_limit][depth_limit] plugin
20
- - Solutions for N+1 problem (via built-in [batch loading](#batch-loading), [preloads][preloads] or
21
- [activerecord_preloads][activerecord_preloads] plugin)
22
- - Load serialized objects by ids ([prepare_initial_objects](#prepare-initial-objects))
23
- - Built-in object presenter ([presenter][presenter] plugin)
24
- - Adding custom metadata (via [metadata][metadata] or
25
- [context_metadata][context_metadata] plugins)
26
- - Value formatters ([formatters][formatters] plugin) helps to transform
27
- time, date, money, percentage, and any other values in the same way
28
- keeping the code dry
29
- - Conditional attributes - ([if][if] plugin)
30
- - Auto camelCase keys - [camel_case][camel_case] plugin
31
- - Serializing Hash records - [hash_access][hash_access] attribute option
32
-
33
- ## Table of Contents
34
-
35
- <!-- toc -->
36
-
37
- - [Installation](#installation)
38
- - [Usage](#usage)
39
- - [Define serializers](#define-serializers)
40
- - [Adding attributes](#adding-attributes)
41
- - [Defining a nested serializer with a block](#defining-a-nested-serializer-with-a-block)
42
- - [Serializing](#serializing)
43
- - [Selecting Fields](#selecting-fields)
44
- - [Using Context](#using-context)
45
- - [Batch Loading](#batch-loading)
46
- - [Prepare Initial Objects](#prepare-initial-objects)
47
- - [Configuration](#configuration)
48
- - [Preloads](#preloads)
49
- - [Serializing the same object in association](#serializing-the-same-object-in-association)
50
- - [Custom preloading](#custom-preloading)
51
- - [Serializing Hash records](#serializing-hash-records)
52
- - [Plugins](#plugins)
53
- - [Plugin :activerecord_preloads](#plugin-activerecord_preloads)
54
- - [Plugin :root](#plugin-root)
55
- - [Plugin :metadata](#plugin-metadata)
56
- - [Plugin :context_metadata](#plugin-context_metadata)
57
- - [Plugin :formatters](#plugin-formatters)
58
- - [Plugin :presenter](#plugin-presenter)
59
- - [Plugin :string_modifiers](#plugin-string_modifiers)
60
- - [Plugin :if](#plugin-if)
61
- - [Plugin :camel_case](#plugin-camel_case)
62
- - [Plugin :depth_limit](#plugin-depth_limit)
63
- - [Plugin :explicit_many_option](#plugin-explicit_many_option)
64
- - [Errors](#errors)
65
- - [Release](#release)
66
- - [Development](#development)
67
- - [Contributing](#contributing)
68
- - [License](#license)
69
-
70
- <!-- tocstop -->
71
-
72
- ## Installation
12
+ **Serialize exactly what was asked. Load exactly what is needed.**
73
13
 
74
- `bundle add serega`
75
-
76
- ## Usage
14
+ [![Gem Version](https://badge.fury.io/rb/serega.svg)](https://badge.fury.io/rb/serega)
15
+ [![GitHub Actions][build-badge]][build]
16
+ ![Coverage 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)
17
+ ![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
18
+ ![Ruby >= 3.3](https://img.shields.io/badge/ruby-%3E%3D%203.3-red)
77
19
 
78
- ### Define serializers
20
+ Serega is a Ruby serializer that turns your objects into Hashes ready for JSON.
21
+ Clients choose the fields they need, and Serega loads only the data those
22
+ fields use — in a few queries, with no N+1.
79
23
 
80
- Most apps should define **base serializer** with common plugins and settings to
81
- not repeat them in each serializer.
24
+ - ⚡ **Fast** — serialization code is generated per field selection and cached.
25
+ [See benchmarks](#benchmarks).
26
+ - 🎯 **Clients choose fields** — `?fields=name,posts(title)` works out of the
27
+ box, nested at any depth.
28
+ - 🧺 **No N+1** — associations are preloaded and batch loaders run once per
29
+ relation, only for the requested fields.
30
+ - 🪶 **No dependencies** — plain Ruby, works with Rails, Hanami, Roda, Sinatra
31
+ or no framework at all.
82
32
 
83
- Serializers will inherit everything (plugins, config, attributes) from their
84
- superclasses.
33
+ ## Show me
85
34
 
86
35
  ```ruby
87
36
  class AppSerializer < Serega
88
- # plugin :one
89
- # plugin :two
90
-
91
- # config.one = :one
92
- # config.two = :two
37
+ plugin :activerecord_preloads # preloads associations used by requested fields
38
+ config.auto_preload = true
39
+ config.hide_by_default = :auto # fields that need extra queries are sent on request
93
40
  end
94
41
 
95
42
  class UserSerializer < AppSerializer
96
- # attribute :one
97
- # attribute :two
98
- end
99
-
100
- class CommentSerializer < AppSerializer
101
- # attribute :one
102
- # attribute :two
103
- end
104
- ```
105
-
106
- ### Adding attributes
107
-
108
- ⚠️ Attribute names are checked to include only "a-z", "A-Z", "0-9", "\_", "-",
109
- "~" characters.
110
-
111
- We allow ONLY these characters as we want to be able to use attribute names in
112
- URLs without escaping.
113
-
114
- This rule can be turned off with simple config option: `config.check_attribute_name = false`
115
-
116
- ```ruby
117
- class UserSerializer < Serega
118
- # Regular attribute
119
- attribute :first_name
120
-
121
- # Option :method specifies the name used to read the attribute's value —
122
- # a method called on the serialized object by default, or a Hash key when
123
- # combined with :hash_access
124
- attribute :first_name, method: :old_first_name
125
-
126
- # Attribute blocks below require a base serializer for nested serializers.
127
- # See the "Defining a nested serializer with a block" section below.
128
- config.base_serializer = Serega
129
-
130
- # Method :itself is handy to serialize the same object with a nested set of
131
- # attributes. Note: with the :presenter plugin the serialized value will be
132
- # the Presenter instance itself; use `method: :__getobj__` to serialize the
133
- # original unwrapped object instead.
134
- attribute :statistics, method: :itself do
135
- attribute :likes_count
136
- attribute :comments_count
137
- end
138
-
139
- # Block defines a nested serializer for the attribute value.
140
- # See the "Defining a nested serializer with a block" section below.
141
- attribute :author do
142
- attribute :name
143
- end
144
-
145
- # Option :value can be used with a Proc or callable object to define
146
- # attribute value
147
- attribute :first_name, value: UserProfile.new # must have #call method
148
- attribute :first_name, value: proc { |user| user.profile&.first_name }
149
-
150
- # Option :delegate can be used to define attribute value.
151
- # Sub-option :allow_nil by default is false
152
- attribute :first_name, delegate: { to: :profile, allow_nil: true }
153
-
154
- # Option :delegate can be used with :method sub-option, so method chain here
155
- # is user.profile.fname
156
- attribute :first_name, delegate: { to: :profile, method: :fname }
157
-
158
- # Option :default can be used to replace possible nil values.
159
- attribute :first_name, default: ''
160
- attribute :is_active, default: false
161
- attribute :comments_count, default: 0
162
-
163
- # Option :const specifies attribute with a specific constant value
164
- attribute(:type, const: 'user')
165
-
166
- # Option :hide specifies attributes that should not be serialized by default
167
- attribute :tags, hide: true
168
-
169
- # Option :serializer specifies nested serializer for attribute
170
- # We can define the `:serializer` value as a Class, String, or Proc.
171
- # Use String or Proc if you have cross-references in serializers.
172
- attribute :posts, serializer: PostSerializer
43
+ attribute :name
173
44
  attribute :posts, serializer: "PostSerializer"
174
- attribute :posts, serializer: -> { PostSerializer }
175
-
176
- # Option `:many` specifies a has_many relationship. It is optional.
177
- # If not specified, it is defined during serialization by checking
178
- # `object.is_a?(Enumerable) && !object.is_a?(Hash) && !object.is_a?(Struct)`
179
- # Also the `:many` changes the default value from `nil` to `[]`.
180
- attribute :posts, serializer: PostSerializer, many: true
181
-
182
- # Option `:preload` allows to specify associations to preload to
183
- # attribute value
184
- attribute :email, preload: :emails, value: proc { |user| user.emails.find(&:verified?) }
185
-
186
- # Options `:if, :unless, :if_value and :unless_value` can be specified
187
- # when `:if` plugin is enabled. They hide the attribute key and value from the
188
- # response.
189
- # See more usage examples in the `:if` plugin section.
190
- attribute :email, if: proc { |user, ctx| user == ctx[:current_user] }
191
- attribute :email, if_value: :present?
192
-
193
- # Option `:format` can be specified when enabled `:formatters` plugin
194
- # It changes the attribute value
195
- attribute :created_at, format: :iso_time
196
- attribute :updated_at, format: :iso_time
197
-
198
- # Option `:format` also can be used as Proc
199
- attribute :created_at, format: proc { |time| time.strftime("%Y-%m-%d")}
200
45
  end
201
- ```
202
-
203
- ### Defining a nested serializer with a block
204
-
205
- An attribute block defines an anonymous nested serializer. The attribute value
206
- is found as usual — by the attribute name or the `:method`, `:value`,
207
- `:delegate`, `:batch` option — and is serialized by this nested serializer.
208
-
209
- The nested serializer is a regular subclass of an explicitly chosen **base
210
- serializer** — usually a settings-only serializer holding your plugins and
211
- configuration. Choose it with the `base_serializer:` attribute option or the
212
- `config.base_serializer` setting (attribute option wins; an error is raised
213
- when none is set):
214
-
215
- ```ruby
216
- class AppSerializer < Serega
217
- plugin :activerecord_preloads
218
-
219
- # Nested serializers defined with attribute blocks will inherit from
220
- # AppSerializer
221
- config.base_serializer = self
222
- end
223
-
224
- class UserSerializer < AppSerializer
225
- attribute :first_name
226
-
227
- # Serializes user.author with a nested serializer inherited from AppSerializer
228
- attribute :author do
229
- attribute :name
230
- end
231
-
232
- # Serializes the user itself, exposing only counters
233
- attribute :statistics, method: :itself, base_serializer: StatsBaseSerializer do
234
- attribute :likes_count
235
- attribute :comments_count
236
- end
237
- end
238
-
239
- UserSerializer.to_h(user)
240
- # => {first_name: "Bruce", author: {name: "Bob"}, statistics: {likes_count: 10, comments_count: 3}}
241
- ```
242
-
243
- The nested serializer inherits everything the base serializer has — plugins,
244
- config, attributes, batch loaders, the preload handler — through the regular
245
- inheritance mechanism. Any serializer can be used as a base: if it has
246
- attributes, they are serialized by the nested serializer too, together with
247
- the attributes defined in the block. Anything the base does not provide can
248
- be declared inside the block:
249
-
250
- ```ruby
251
- attribute :statistics, method: :itself do
252
- batch(:stats) { |users| Stats.for_users(users) } # => { user_id => stat }
253
-
254
- attribute :likes_count, batch: :stats, value: proc { |user, batches:| batches[:stats][user.id].likes }
255
- end
256
- ```
257
-
258
- Things to keep in mind:
259
-
260
- - The nested serializer is created at the moment the attribute is defined.
261
- Changes made to the base serializer later do not affect already defined
262
- nested serializers.
263
- - Errors raised while serializing nested attributes are reported with a
264
- readable serializer label, for example:
265
- `(when serializing 'comments_count' attribute in UserSerializer.<statistics>)`.
266
-
267
- ### Serializing
268
-
269
- We can serialize objects using class method `.call` aliased as `.to_h` and
270
- same instance methods `#call` and its alias `#to_h`.
271
-
272
- ```ruby
273
- user = OpenStruct.new(username: 'serega')
274
-
275
- class UserSerializer < Serega
276
- attribute :username
277
- end
278
-
279
- UserSerializer.to_h(user) # => {username: "serega"}
280
- UserSerializer.to_h([user]) # => [{username: "serega"}]
281
- ```
282
-
283
- Use `.to_data` / `#to_data` to get the same result as Ruby `Data` objects
284
- (immutable value objects, Ruby 3.2+). Nested serialized relations are also
285
- converted to `Data` objects.
286
-
287
- ```ruby
288
- result = UserSerializer.to_data(user)
289
- result # => #<data username="serega">
290
- result.username # => "serega"
291
-
292
- UserSerializer.to_data([user]) # => [#<data username="serega">]
293
- ```
294
-
295
- If serialized fields are constant, then it's a good idea to initiate the
296
- serializer and reuse it.
297
- It will be a bit faster (the serialization plan will be prepared only once).
298
-
299
- ```ruby
300
- # Example with all fields
301
- serializer = UserSerializer.new
302
- serializer.to_h(user1)
303
- serializer.to_h(user2)
304
-
305
- # Example with custom fields
306
- serializer = UserSerializer.new(only: [:username, :avatar])
307
- serializer.to_h(user1)
308
- serializer.to_h(user2)
309
- ```
310
-
311
- ### Selecting Fields
312
-
313
- By default, all attributes are serialized (except marked as `hide: true`).
314
-
315
- We can provide **modifiers** to select serialized attributes:
316
-
317
- - *only* - lists specific attributes to serialize;
318
- - *except* - lists attributes to not serialize;
319
- - *with* - lists attributes to serialize additionally (By default all attributes
320
- are exposed and will be serialized, but some attributes can be hidden when
321
- they are defined with the `hide: true` option, more on this below. `with`
322
- modifier can be used to expose such attributes).
323
-
324
- Modifiers can be provided as Hash, Array, String, Symbol, or their combinations.
325
46
 
326
- With plugin [string_modifiers][string_modifiers] we can provide modifiers as
327
- single `String` with attributes split by comma `,` and nested values inside
328
- brackets `()`, like: `first_name,last_name,addresses(line1,line2)`. This can be very useful
329
- to accept the list of fields in **GET** requests.
47
+ class PostSerializer < AppSerializer
48
+ batch :comments_count, ->(posts) { Comment.where(post: posts).group(:post_id).count }
330
49
 
331
- When a non-existing attribute is provided, the `Serega::AttributeNotExist` error
332
- will be raised. This error can be muted with the `check_initiate_params: false`
333
- option.
334
-
335
- ```ruby
336
- class UserSerializer < Serega
337
- plugin :string_modifiers # to send all modifiers in one string
338
-
339
- attribute :username
340
- attribute :first_name
341
- attribute :last_name
342
- attribute :email, hide: true
343
- attribute :enemies, serializer: UserSerializer, hide: true
344
- end
345
-
346
- joker = OpenStruct.new(
347
- username: 'The Joker',
348
- first_name: 'jack',
349
- last_name: 'Oswald White',
350
- email: 'joker@mail.com',
351
- enemies: []
352
- )
353
-
354
- bruce = OpenStruct.new(
355
- username: 'Batman',
356
- first_name: 'Bruce',
357
- last_name: 'Wayne',
358
- email: 'bruce@wayneenterprises.com',
359
- enemies: []
360
- )
361
-
362
- joker.enemies << bruce
363
- bruce.enemies << joker
364
-
365
- # Default
366
- UserSerializer.to_h(bruce)
367
- # => {:username=>"Batman", :first_name=>"Bruce", :last_name=>"Wayne"}
368
-
369
- # With `:only` modifier
370
- fields = [:username, { enemies: [:username, :email] }]
371
- fields_as_string = 'username,enemies(username,email)'
372
-
373
- UserSerializer.to_h(bruce, only: fields)
374
- UserSerializer.new(only: fields).to_h(bruce)
375
- UserSerializer.new(only: fields_as_string).to_h(bruce)
376
- # =>
377
- # {
378
- # :username=>"Batman",
379
- # :enemies=>[{:username=>"The Joker", :email=>"joker@mail.com"}]
380
- # }
381
-
382
- # With `:except` modifier
383
- fields = %i[first_name last_name]
384
- fields_as_string = 'first_name,last_name'
385
- UserSerializer.new(except: fields).to_h(bruce)
386
- UserSerializer.to_h(bruce, except: fields)
387
- UserSerializer.to_h(bruce, except: fields_as_string)
388
- # => {:username=>"Batman"}
389
-
390
- # With `:with` modifier
391
- fields = %i[email enemies]
392
- fields_as_string = 'email,enemies'
393
- UserSerializer.new(with: fields).to_h(bruce)
394
- UserSerializer.to_h(bruce, with: fields)
395
- UserSerializer.to_h(bruce, with: fields_as_string)
396
- # =>
397
- # {
398
- # :username=>"Batman",
399
- # :first_name=>"Bruce",
400
- # :last_name=>"Wayne",
401
- # :email=>"bruce@wayneenterprises.com",
402
- # :enemies=>[
403
- # {:username=>"The Joker", :first_name=>"jack", :last_name=>"Oswald White"}
404
- # ]
405
- # }
406
-
407
- # With no existing attribute
408
- fields = %i[first_name enemy]
409
- fields_as_string = 'first_name,enemy'
410
- UserSerializer.new(only: fields).to_h(bruce)
411
- UserSerializer.to_h(bruce, only: fields)
412
- UserSerializer.to_h(bruce, only: fields_as_string)
413
- # => raises Serega::AttributeNotExist
414
-
415
- # With no existing attribute and disabled validation
416
- fields = %i[first_name enemy]
417
- fields_as_string = 'first_name,enemy'
418
- UserSerializer.new(only: fields, check_initiate_params: false).to_h(bruce)
419
- UserSerializer.to_h(bruce, only: fields, check_initiate_params: false)
420
- UserSerializer.to_h(bruce, only: fields_as_string, check_initiate_params: false)
421
- # => {:first_name=>"Bruce"}
422
- ```
423
-
424
- ### Using Context
425
-
426
- Sometimes it can be required to use the context during serialization, like
427
- current_user or any.
428
-
429
- ```ruby
430
- class UserSerializer < Serega
431
- attribute :email, value: proc { |user, ctx|
432
- user.email if ctx[:current_user] == user
433
- }
434
- end
435
-
436
- user = OpenStruct.new(email: 'email@example.com')
437
- UserSerializer.(user, context: {current_user: user})
438
- # => {:email=>"email@example.com"}
439
-
440
- UserSerializer.new.to_h(user, context: {current_user: user}) # same
441
- # => {:email=>"email@example.com"}
442
- ```
443
-
444
- ### Batch Loading
445
-
446
- Serega includes built-in batch loading functionality to efficiently load data on demand and solve N+1 problems.
447
-
448
- #### Defining Named Batch Loaders
449
-
450
- Named loaders can be defined using the `batch` class method and reused across attributes:
451
-
452
- ```ruby
453
- class UserSerializer < Serega
454
- # Define named loaders
455
- batch :comments_count, ->(users) { Comment.where(user: users).group(:user_id).count }
456
- batch :comments_count, CommentsCountLoader # Example with callable class
457
-
458
- # Use a loader by its name
459
- attribute :comments_count, batch: :comments_count
460
-
461
- # Equivalent — when the attribute name matches the loader name
50
+ attribute :title
462
51
  attribute :comments_count, batch: true
463
-
464
- # Equivalent — the default value resolution spelled out
465
- attribute :comments_count, batch: :comments_count,
466
- value: proc { |user, batches:| batches[:comments_count][user.id] }
467
-
468
- # Hash form — needed only for sub-options, for example a custom `:id` method
469
- attribute :comments_count, batch: { use: :comments_count, id: :uuid }
470
- end
471
- ```
472
-
473
- #### Using Custom Loaders
474
-
475
- Custom loaders can be provided directly using the `:use` option with any callable object:
476
-
477
- ```ruby
478
- class UserSerializer < Serega
479
- # Inline proc loader
480
- attribute :followers_count,
481
- batch: { use: ->(users) { Follow.where(user: users).group(:user_id).count } }
482
-
483
- # Callable class loader
484
- attribute :likes_count, batch: { use: LikesCountLoader }
485
-
486
- # Method reference
487
- attribute :views_count, batch: { use: ViewsCounter.method(:batch_load) }
488
- end
489
- ```
490
-
491
- #### Using Multiple Loaders in Same Attribute
492
-
493
- Custom loaders can be provided directly using the `:use` option with any callable object:
494
-
495
- ```ruby
496
- class UserSerializer < Serega
497
- # Define named loaders
498
- batch :facebook_likes, FacebookLikesLoader
499
- batch :twitter_likes, TwitterLikesLoader
500
-
501
- # Summarize likes
502
- attribute :likes_count,
503
- batch: { use: [:facebook_likes, :twitter_likes] },
504
- value: proc { |user, batches:| batches[:facebook_likes][user.id] + batches[:twitter_likes][user.id] }
505
- end
506
- ```
507
-
508
- ### Prepare Initial Objects
509
-
510
- `prepare_initial_objects` replaces the serialized objects before serialization
511
- starts, so a serializer can accept ids or other references and load the records
512
- itself.
513
-
514
- ```ruby
515
- class UserSerializer < Serega
516
- # OccamsRecord returns read-only records — faster and with a much lower
517
- # memory footprint than ActiveRecord models
518
- prepare_initial_objects { |user_ids| OccamsRecord.query(User.where(id: user_ids)).run }
519
-
520
- attribute :first_name
521
52
  end
522
53
 
523
- UserSerializer.to_h(["17", "42"]) # => [{first_name: "Ann"}, {first_name: "Bob"}]
524
- ```
525
-
526
- The handler accepts the serialization context as a second positional or a
527
- `:ctx` keyword argument, and can be provided as a callable value:
528
-
529
- ```ruby
530
- class UserSerializer < Serega
531
- prepare_initial_objects { |ids, ctx| OccamsRecord.query(User.where(id: ids, account: ctx[:account])).run }
532
- # or
533
- prepare_initial_objects { |ids, ctx:| OccamsRecord.query(User.where(id: ids, account: ctx[:account])).run }
534
- # or
535
- prepare_initial_objects UsersLoader
536
- end
537
- ```
538
-
539
- The handler runs once per serialization, before the `:many` option is detected,
540
- so it can turn a single object into a collection and back:
541
-
542
- ```ruby
543
- class UserSerializer < Serega
544
- prepare_initial_objects { |user_id| OccamsRecord.query(User.where(id: user_id)).run }
545
-
546
- attribute :first_name
547
- end
548
-
549
- UserSerializer.to_h("17") # => [{first_name: "Ann"}] - an array, as the handler returned a collection
550
- ```
551
-
552
- A provided `:many` option is used as is. A handler returning `nil` serializes
553
- `nil`, and errors raised inside the handler are not wrapped.
554
-
555
- Declared preloads are applied to the prepared objects, so the handler pairs
556
- with [preloads][preloads] and the
557
- [activerecord_preloads][activerecord_preloads] plugin:
558
-
559
- ```ruby
560
- class UserSerializer < Serega
561
- plugin :activerecord_preloads
562
-
563
- prepare_initial_objects { |user_ids| User.where(id: user_ids) }
564
-
565
- attribute :albums_count, preload: :albums, value: proc { |user| user.albums.size }
566
- end
567
- ```
568
-
569
- ⚠️ The `:preload` option needs ActiveRecord objects. When the handler returns
570
- anything else — OccamsRecord read-only records, Structs, Hashes, plain objects
571
- — preloading raises `Serega::SeregaError` (`Can't preload ... to ...`) during
572
- serialization. Load that data with [batch loading](#batch-loading) instead,
573
- which works with any objects.
574
-
575
- The handler runs for the serialized objects only, and not for objects of nested
576
- serializers. It is inherited by subclasses, so declare it on concrete
577
- serializers rather than on a base serializer shared by all of them.
578
-
579
- ## Configuration
580
-
581
- Here are the default options. Other options can be added with plugins.
582
-
583
- ⚠️ Attributes are prepared at the moment they are defined. If a config option
584
- influences attributes (`auto_preload`, `hide_by_default`, `batch_id_option`,
585
- formatters, etc.), changing it affects only attributes defined **after** the
586
- change — including nested serializers defined with attribute blocks, which
587
- are created at their definition point. Configure the serializer before
588
- defining attributes.
589
-
590
- ```ruby
591
- class AppSerializer < Serega
592
- # With `activerecord_preloads` plugin it automatically adds `preload` option
593
- # to attributes with `:delegate` or `:serializer` option.
594
- # It helps to preload associations automatically, omitting N+1 requests.
595
- config.auto_preload = false
596
-
597
- # Hides attributes by default. Accepts:
598
- # false - nothing hidden (default)
599
- # true - all attributes hidden
600
- # [:preload] - attributes with :preload option hidden
601
- # [:batch] - attributes with :batch option hidden
602
- # [:preload, :batch] - either option triggers hiding
603
- # Useful to avoid extra DB requests for attributes that were not requested.
604
- config.hide_by_default = false
605
-
606
- # Default method used on serialized object to resolve batch value
607
- # For example:
608
- # attribute :counter, batch: CounterBatchLoader
609
- # # Attribute values will be resolved as:
610
- # proc { |object, batches:| batches[:counter][object.id] }
611
- config.batch_id_option = :id
612
-
613
- # Defaults for the `hash_access:` attribute option — `default_mode` is
614
- # what `hash_access: true` (and a Hash form omitting :mode) resolves to.
615
- # See "Serializing Hash records".
616
- config.hash_access.default_mode = :symbol # the default
617
- config.hash_access.default_allow_missing_key = false # the default
54
+ UserSerializer.to_h(User.all)
55
+ # => [{name: "Bruce Wayne"}, ...] 1 query
618
56
 
619
- # Parent class for nested serializers defined with attribute blocks.
620
- # Usually a settings-only serializer, e.g. `config.base_serializer = self`
621
- # in an application base serializer class. There is no default — an
622
- # attribute block raises an error when no base serializer is chosen (it can
623
- # also be provided per-attribute with the `base_serializer:` option).
624
- config.base_serializer = self
625
-
626
- # Disable/enable validation of modifiers (`:with, :except, :only`)
627
- # By default, this validation is enabled.
628
- # After disabling, all requested incorrect attributes will be skipped.
629
- config.check_initiate_params = false # default is true, enabled
630
-
631
- # Stores in memory prepared `plans` - list of serialized attributes.
632
- # Next time serialization happens with the same modifiers (`only, except, with`),
633
- # we will reuse already prepared `plans`.
634
- # This defines storage size (count of stored `plans` with different modifiers).
635
- config.max_cached_plans_per_serializer_count = 50 # default is 0, disabled
636
- end
57
+ UserSerializer.to_h(User.all, only: "name,posts(title,comments_count)")
58
+ # => [{name: "Bruce Wayne", posts: [{title: "Gotham at night", comments_count: 12}, ...]}, ...]
59
+ # 3 queries, for any number of users
637
60
  ```
638
61
 
639
- ## Preloads
640
-
641
- Serega includes built-in preloads functionality that lets you declare
642
- `:preload` on attributes. It is used by the `:activerecord_preloads` plugin:
643
- enable it and every association you declare is loaded once during
644
- serialization, automatically, with no N+1.
645
-
646
- Configuration options:
647
-
648
- - `config.auto_preload` - default `false` (use `true` or `{ has_delegate_option: true, has_serializer_option: true }`)
649
- - `config.hide_by_default` - default `false` (use `:auto` to hide attributes with preloads or batch loaders)
650
-
651
- These options are extremely useful if you want to forget about finding
652
- preloads manually.
653
-
654
- The `:preload` value is passed to the preload handler exactly as written — a
655
- Symbol, Array, Hash, or any custom value your ORM understands. Preloads can be
656
- disabled with `preload: false` (or `preload: nil`); `preload: true` is not a
657
- valid value. Automatically added preloads can be overwritten with the manually
658
- specified `preload: :xxx` option.
659
-
660
- The actual loading is performed by a handler registered with `preload_with`. The
661
- `:activerecord_preloads` plugin registers one for you; to preload with another
662
- ORM — or to attach data to plain non-ORM objects by your own rules — register
663
- your own (see [Custom preloading](#custom-preloading)). Declaring `:preload`
664
- without a registered handler raises an error, so a preload never silently does
665
- nothing.
666
-
667
- For some examples, **please read the comments in the code below**
668
-
669
- ```ruby
670
- class AppSerializer < Serega
671
- config.auto_preload = true
672
- config.hide_by_default = :auto
673
- end
674
-
675
- class UserSerializer < AppSerializer
676
- # No preloads
677
- attribute :username
678
-
679
- # `preload: :user_stats` added manually
680
- attribute :followers_count, preload: :user_stats,
681
- value: proc { |user| user.user_stats.followers_count }
682
-
683
- # `preload: :user_stats` added automatically, as
684
- # `auto_preload` includes `has_delegate_option: true`
685
- attribute :comments_count, delegate: { to: :user_stats }
686
-
687
- # `preload: :albums` added automatically as
688
- # `auto_preload` includes `has_serializer_option: true`
689
- attribute :albums, serializer: 'AlbumSerializer'
690
- end
691
-
692
- class AlbumSerializer < AppSerializer
693
- attribute :images_count, delegate: { to: :album_stats }
694
- end
62
+ ## Installation
695
63
 
696
- # With `hide_by_default = :auto`, attributes that declare a preload are hidden
697
- # unless explicitly requested with `:with`. When requested, their associations
698
- # are preloaded automatically. For example:
699
- #
700
- # UserSerializer.to_h(users, with: [:followers_count, { albums: :images_count }])
701
- #
702
- # preloads `:user_stats` and `:albums` on the users, and `:album_stats` on the
703
- # albums.
64
+ ```sh
65
+ bundle add serega
704
66
  ```
705
67
 
706
- ---
707
-
708
- ### Serializing the same object in association
68
+ ## Quick start
709
69
 
710
- For example, you show your current user as "user" and use the same user object
711
- to serialize "user_stats". `UserStatSerializer` relies on user fields and any
712
- other user associations. Specify `preload: nil` so that nothing is preloaded for
713
- this attribute on the "user" object — the associations declared in
714
- `UserStatSerializer` are still preloaded onto the same user object.
70
+ Define a base serializer with the setup your app uses. Every serializer
71
+ inherits it.
715
72
 
716
73
  ```ruby
74
+ # app/serializers/app_serializer.rb
717
75
  class AppSerializer < Serega
76
+ plugin :activerecord_preloads
718
77
  config.auto_preload = true
719
- config.hide_by_default = :auto
720
78
  end
721
79
 
80
+ # app/serializers/user_serializer.rb
722
81
  class UserSerializer < AppSerializer
723
- attribute :username
724
- attribute :user_stats,
725
- serializer: 'UserStatSerializer',
726
- value: proc { |user| user },
727
- preload: nil
728
- end
729
- ```
730
-
731
- ---
732
-
733
- ### Custom preloading
734
-
735
- The `:activerecord_preloads` plugin works out of the box, but the preload
736
- mechanism itself is not tied to ActiveRecord — or to ORMs at all. Register a
737
- handler with `preload_with` and it is called once per preloaded attribute with
738
- the gathered objects and that attribute's `:preload` value, before the
739
- attribute is serialized:
740
-
741
- ```ruby
742
- class UserSerializer < Serega
82
+ attribute :id
743
83
  attribute :name
744
- attribute :posts, serializer: PostSerializer, preload: :posts
745
-
746
- # `objects` is the gathered users; `preloads` is this attribute's
747
- # `:preload` value (`:posts`). `MyORM.preload` does the eager loading.
748
- preload_with { |objects, preloads| MyORM.preload(objects, preloads) }
84
+ attribute :email, value: proc { |user, ctx| user.email if user == ctx[:current_user] }
85
+ attribute :avatar_url, hide: true
749
86
  end
750
- ```
751
-
752
- The handler can be a block or any callable taking `(objects, preloads)`. It is
753
- inherited by child serializers and can be overridden per subclass. The
754
- `:preload` value reaches the handler untouched, so you can pass whatever your
755
- handler expects — not necessarily something an ORM understands. The gathered
756
- objects can be plain Ruby objects too: the handler decides where the data comes
757
- from (another ORM, an HTTP API, a cache) and how to attach it to the objects,
758
- by whatever rules you define.
759
-
760
- The handler runs **once per preloaded attribute**, not once per distinct
761
- `:preload` value. If the same `:preload` is declared on several attributes, the
762
- handler is invoked once for each of them over the same objects, so it should
763
- check whether the data is already loaded before loading it again. (The
764
- `:activerecord_preloads` handler relies on `ActiveRecord::Associations::Preloader`,
765
- which already skips associations that are loaded.)
766
87
 
767
- Because you choose the `:preload` value, it doubles as a **discriminator**: give
768
- two attributes different `:preload` values and branch on `preloads` inside the
769
- handler — no need for the value to be a real association name.
88
+ # app/controllers/users_controller.rb
89
+ class UsersController < ApplicationController
90
+ def index
91
+ users = UserSerializer.to_json(
92
+ User.all,
93
+ only: params[:fields], # "id,name" from GET /users?fields=id,name
94
+ context: {current_user: current_user}
95
+ )
770
96
 
771
- ```ruby
772
- class PostSerializer < Serega
773
- attribute :owner, serializer: UserSerializer, preload: :owner
774
- attribute :author, serializer: UserSerializer, preload: :author
775
-
776
- # Both attributes load the same person, but from different sources.
777
- preload_with do |objects, preloads|
778
- case preloads
779
- when :owner then load_owners(objects) # e.g. from the DB
780
- when :author then load_authors(objects) # e.g. from an external API
781
- else MyORM.preload(objects, preloads)
782
- end
97
+ render json: users
783
98
  end
784
99
  end
785
100
  ```
786
101
 
787
- ---
788
-
789
- ## Serializing Hash records
102
+ ## Benchmarks
790
103
 
791
- (objects should implement `#[]`, `#fetch`, and `#key?` - Hash already has them)
104
+ Serega is on par with the fastest Ruby serializers in speed and allocations,
105
+ and in some cases beats them. Results, the details of each case and how to run
106
+ them: [benchmarks/README.md](https://github.com/andreyruby/serega/blob/master/benchmarks/README.md).
792
107
 
793
- The `hash_access: <mode>` attribute option makes an attribute read its value from
794
- Hash keys instead of calling a method.
108
+ ## Features
795
109
 
796
- Allowed `<mode>`:
110
+ ### Select fields
797
111
 
798
- - `:symbol` reads record[:name]
799
- - `:string` reads record["name"]
800
- - `true` shorthand for `config.hash_access.default_mode` (`:symbol` by default)
801
-
802
- By default missing keys will raise `KeyError`.
803
-
804
- `config.hash_access.default_allow_missing_key = true` will change `KeyError`
805
- to return `nil` value instead. `config.hash_access.default_mode = <mode>`
806
- changes what `true` (and a Hash form omitting `:mode`) resolves to.
112
+ `only`, `except` and `with` accept Symbols, Arrays, Hashes or a string like
113
+ `"name,posts(title)"`. Unknown fields raise `Serega::AttributeNotExist`.
807
114
 
808
115
  ```ruby
809
- class UserSerializer < Serega
810
- config.hash_access.default_mode = :symbol # the default
811
- config.hash_access.default_allow_missing_key = true # false by default
812
-
813
- attribute :name, hash_access: true # reads record[:name] (config.hash_access.default_mode)
814
- attribute :name, hash_access: :symbol # reads record[:name]
815
- attribute :name, hash_access: :string # reads record["name"]
816
-
817
- # Use long form if you need to override `allow_missing_key`
818
- attribute :name, hash_access: { allow_missing_key: false }
819
- attribute :name, hash_access: { mode: :symbol, allow_missing_key: false }
820
- attribute :name, hash_access: { mode: :string, allow_missing_key: false }
821
- end
116
+ UserSerializer.to_h(user, only: [:name, {posts: [:title]}])
117
+ UserSerializer.to_h(user, except: :email)
118
+ UserSerializer.to_h(user, with: :avatar_url) # adds hidden fields
822
119
  ```
823
120
 
824
- Delegated attributes configure hash access **per step**:
825
-
826
- - `to_hash_access` configures reading `<mode>` for intermediate object
827
- - `hash_access` configures reading `<mode>` for final key
828
-
829
- ```ruby
830
- # reads `record.address.city` (no hash access)
831
- attribute :city, delegate: { to: :address } # record.address.city
121
+ → [Selecting fields guide](docs/selecting-fields.md)
832
122
 
833
- # reads record.address[:city]
834
- attribute :city, delegate: { to: :address, hash_access: :symbol }
123
+ ### Batch loading
835
124
 
836
- # reads record[:address].city
837
- attribute :city, delegate: { to: :address, to_hash_access: :symbol }
838
-
839
- # reads record[:address][:city]
840
- attribute :city, delegate: { to: :address, to_hash_access: :symbol, hash_access: :symbol }
841
-
842
- # reads record["address"] and if exists returns record["address"][:city]
843
- attribute :city,
844
- delegate: {
845
- to: :address,
846
- allow_nil: true,
847
- to_hash_access: { mode: :string, allow_missing_key: true },
848
- hash_access: { mode: :symbol, allow_missing_key: false }
849
- }
850
- ```
851
-
852
- ## Plugins
853
-
854
- ### Plugin :activerecord_preloads
855
-
856
- Automatically preloads associations to serialized objects.
857
-
858
- Every association you declare with `:preload` is loaded once during
859
- serialization using `ActiveRecord::Associations::Preloader`, so there are no
860
- N+1 queries. The plugin does this by registering a [`preload_with`](#custom-preloading)
861
- handler; to use a different ORM, register your own instead.
125
+ Load data for all serialized objects with one call.
862
126
 
863
127
  ```ruby
864
- class AppSerializer < Serega
865
- config.auto_preload = true
866
- plugin :activerecord_preloads
867
- end
868
-
869
- class AlbumSerializer < AppSerializer
870
- # no preloads
871
- attribute :title
872
-
873
- # preloads :downloads, as manually specified
874
- attribute :downloads_count, preload: :downloads, value: proc { |album| album.downloads.count }
875
- end
876
-
877
128
  class UserSerializer < AppSerializer
878
- # no preloads
879
- attribute :username
129
+ batch :followers_count, ->(users) { Follow.where(user: users).group(:user_id).count }
880
130
 
881
- # preloads :user_stats, as auto_preload is enabled for :delegate attributes
882
- attribute :comments_count, delegate: { to: :user_stats }
883
-
884
- # preloads :albums, as auto_preload is enabled for :serializer attributes
885
- attribute :albums, serializer: AlbumSerializer
131
+ attribute :followers_count, batch: true
886
132
  end
887
-
888
- UserSerializer.to_h(users)
889
- # 1 query to load :user_stats for all users
890
- # + 1 query to load :albums for all users
891
- # + 1 query to load :downloads for all albums
892
- # = 3 queries total, regardless of how many users/albums are serialized
893
133
  ```
894
134
 
895
- ### Plugin :root
896
-
897
- Adds a root key to serialized data.
898
-
899
- Accepts options:
900
-
901
- - :root - specifies root for all responses
902
- - :root_one - specifies the root key for single object serialization only
903
- - :root_many - specifies the root key for multiple objects serialization only
904
-
905
- Adds additional config options:
906
-
907
- - config.root.one
908
- - config.root.many
909
- - config.root.one=
910
- - config.root.many=
911
-
912
- The default root is `:data`.
913
-
914
- ```ruby
915
- class UserSerializer < Serega
916
- plugin :root # default root is :data
917
- end
918
-
919
- class UserSerializer < Serega
920
- plugin :root, root: :users
921
- end
922
-
923
- class UserSerializer < Serega
924
- plugin :root, root_one: :user, root_many: :people
925
- end
926
-
927
- class UserSerializer < Serega
928
- plugin :root, root: nil # no root key by default
929
- end
930
- ```
931
-
932
- The root key can also be changed per serialization, or removed entirely by
933
- providing `root: nil` (it can still be added back per serialization).
934
-
935
- ```ruby
936
- class UserSerializer < Serega
937
- plugin :root
938
- end
939
-
940
- UserSerializer.to_h(nil) # => {:data=>nil}
941
- UserSerializer.to_h(nil, root: :user) # => {:user=>nil}
942
- UserSerializer.to_h(nil, root: nil) # => nil
943
- ```
944
-
945
- ### Plugin :metadata
946
-
947
- Depends on: [`:root`][root] plugin, that must be loaded first
948
-
949
- Adds metadata to the serialized response via the class-level `meta_attribute`
950
- method, which accepts:
951
-
952
- - `*path` [Array of Symbols] - nested hash keys.
953
- - `**options` [Hash]
135
+ → [Batch loading guide](docs/batch-loading.md) ·
136
+ [Preloads guide](docs/preloads.md)
954
137
 
955
- - `:const` - describes metadata value (if it is constant)
956
- - `:value` - describes metadata value as any `#callable` instance
957
- - `:hide_nil` - does not show the metadata key if the value is nil.
958
- It is `false` by default
959
- - `:hide_empty` - does not show the metadata key if the value is nil or empty.
960
- It is `false` by default.
138
+ ### Formatters
961
139
 
962
- - `&block` [Proc] - describes value for the current meta attribute
140
+ Format dates, money and other values the same way everywhere.
963
141
 
964
142
  ```ruby
965
143
  class AppSerializer < Serega
966
- plugin :root
967
- plugin :metadata
968
-
969
- meta_attribute(:version, const: '1.2.3')
970
- meta_attribute(:ab_tests, :names, value: ABTests.new.method(:names))
971
- meta_attribute(:meta, :paging, hide_nil: true) do |records, ctx|
972
- next unless records.respond_to?(:total_count)
973
-
974
- {
975
- page: records.page,
976
- per_page: records.per_page,
977
- total_count: records.total_count
978
- }
979
- end
144
+ formatter :iso8601, [:iso8601, 3], allow_nil: true
980
145
  end
981
146
 
982
- AppSerializer.to_h(nil)
983
- # => {:data=>nil, :version=>"1.2.3", :ab_tests=>{:names=> ... }}
984
- ```
985
-
986
- ### Plugin :context_metadata
987
-
988
- Depends on: [`:root`][root] plugin, that must be loaded first
989
-
990
- Adds metadata supplied per serialization call (as opposed to `:metadata`,
991
- which is defined statically on the serializer).
992
-
993
- Accepts option `:context_metadata_key` with the name of the root metadata keyword.
994
- By default, it has the `:meta` value.
995
-
996
- The key can be changed in children serializers using this method:
997
- `config.context_metadata.key=(value)`.
998
-
999
- ```ruby
1000
- class UserSerializer < Serega
1001
- plugin :root, root: :data
1002
- plugin :context_metadata, context_metadata_key: :meta
1003
-
1004
- # Same:
1005
- # plugin :context_metadata
1006
- # config.context_metadata.key = :meta
147
+ class UserSerializer < AppSerializer
148
+ attribute :created_at, format: :iso8601
1007
149
  end
1008
-
1009
- UserSerializer.to_h(nil, meta: { version: '1.0.1' })
1010
- # => {:data=>nil, :version=>"1.0.1"}
1011
150
  ```
1012
151
 
1013
- ### Plugin :formatters
152
+ → [Formatters guide](docs/formatters.md)
1014
153
 
1015
- Defines named value formatters once and applies them to any attribute.
154
+ ### Hashes, Structs and Data
1016
155
 
1017
- Use `config.formatters.add()` to register formatters. The `:format`
1018
- attribute option then accepts a formatter name or a callable directly.
1019
-
1020
- Formatters receive up to 2 parameters: the value and the context.
156
+ Serialize Hash records, and get results as `Struct` or `Data` objects.
1021
157
 
1022
158
  ```ruby
1023
- class AppSerializer < Serega
1024
- plugin :formatters, formatters: {
1025
- iso8601: ->(value) { value.iso8601 },
1026
- on_off: ->(value) { value ? 'ON' : 'OFF' },
1027
- money: ->(value) { value.round(2) },
1028
- date: DateTypeFormatter # callable
1029
- }
159
+ class EventSerializer < Serega
160
+ attribute :title, hash_access: true # reads event[:title]
1030
161
  end
1031
162
 
1032
- class UserSerializer < Serega
1033
- # Additionally we can add formatters via config in subclasses
1034
- config.formatters.add(
1035
- iso8601: ->(value) { value.iso8601 },
1036
- on_off: ->(value) { value ? 'ON' : 'OFF' },
1037
- money: ->(value) { value.round(2) }
1038
- )
1039
-
1040
- # Using predefined formatter
1041
- attribute :commission, format: :money
1042
- attribute :is_logged_in, format: :on_off
1043
- attribute :created_at, format: :iso8601
1044
- attribute :updated_at, format: :iso8601
1045
-
1046
- # Using `callable` formatter
1047
- attribute :score_percent, format: PercentFormatter # callable class
1048
- attribute :score_percent, format: proc { |percent| "#{percent.round(2)}%" }
1049
- end
163
+ UserSerializer.to_h(user) # => {name: "Bruce Wayne"}
164
+ UserSerializer.to_struct(user) # => #<struct name="Bruce Wayne">
165
+ UserSerializer.to_data(user) # => #<data name="Bruce Wayne">
1050
166
  ```
1051
167
 
1052
- ### Plugin :presenter
168
+ → [Hash records guide](docs/hash-records.md) ·
169
+ [Serializing guide](docs/serializing.md)
1053
170
 
1054
- Moves computed attribute logic out of blocks and into a dedicated `Presenter` class,
1055
- keeping serializers readable as schemas rather than bags of lambdas.
171
+ ### Ractors
1056
172
 
1057
- Without the plugin, computed attributes live as inline blocks:
173
+ Serialize in parallel Ractors.
1058
174
 
1059
175
  ```ruby
1060
176
  class UserSerializer < Serega
1061
- attribute :name, value: proc { |u| [u.first_name, u.last_name].compact.join(' ') }
1062
- attribute :role, value: proc { |u, ctx| u == ctx[:current_user] ? :self : :other }
1063
- end
1064
- ```
1065
-
1066
- With the plugin, they move into a `presenter do ... end` block:
1067
-
1068
- ```ruby
1069
- class UserSerializer < Serega
1070
- plugin :presenter
1071
-
1072
177
  attribute :name
1073
- attribute :role
1074
-
1075
- presenter do
1076
- def name
1077
- [first_name, last_name].compact.join(' ')
1078
- end
1079
-
1080
- def role
1081
- id == __ctx__[:current_user_id] ? :self : :other
1082
- end
1083
- end
1084
- end
1085
- ```
1086
-
1087
- The block is evaluated inside the serializer's own `Presenter` class, so
1088
- multiple `presenter` blocks accumulate.
1089
-
1090
- `Presenter` inherits from `SimpleDelegator`, so every method of the serialized
1091
- object is available directly inside presenter methods. Any method not explicitly
1092
- defined on `Presenter` is resolved via `method_missing` on the first call, which
1093
- also defines a real delegator method — so all subsequent serializations call it
1094
- directly, without going through `method_missing` again.
1095
-
1096
- The original wrapped object is accessible via `__getobj__` (standard
1097
- `SimpleDelegator` API) when you need an unambiguous reference to it.
1098
-
1099
- The serialization context is accessible via the private method `__ctx__`.
1100
-
1101
- Objects are wrapped in the `Presenter` only when the serializer's `Presenter`
1102
- class (or an inherited one) actually defines custom methods. Loading the
1103
- plugin in a base serializer adds no overhead to serializers that don't
1104
- customize their presenters — their attribute values, batch loaders and value
1105
- callables keep receiving the raw objects.
1106
-
1107
- ### Plugin :string_modifiers
1108
-
1109
- Allows `:only`, `:except` and `:with` to be given as a single comma-separated
1110
- string, with nested attributes in parentheses. Useful for accepting a field
1111
- list straight from a query parameter.
1112
-
1113
- Modifiers can still be provided the old way, as nested hashes or arrays.
1114
-
1115
- ```ruby
1116
- PostSerializer.plugin :string_modifiers
1117
- PostSerializer.new(only: "id,user(id,username)").to_h(post)
1118
- PostSerializer.new(except: "user(username,email)").to_h(post)
1119
- PostSerializer.new(with: "user(email)").to_h(post)
1120
-
1121
- PostSerializer.new(with: {user: %i[email, username]}).to_h(post)
1122
- ```
1123
-
1124
- ### Plugin :if
1125
-
1126
- Adds `:if`, `:unless`, `:if_value`, `:unless_value` attribute options to
1127
- conditionally remove attributes from the response.
1128
-
1129
- `:if`/`:unless` receive the serialized object and context, and are checked
1130
- before the attribute value is found. `:if_value`/`:unless_value` receive the
1131
- already-found value and context, checked after. The latter two cannot be
1132
- used with the `:serializer` option, since a relationship has no "serialized
1133
- value" of its own — use `:if`/`:unless` instead.
1134
-
1135
- See also a `:hide` option that is available without any plugins to hide
1136
- attribute without conditions.
1137
- Look at [select serialized fields](#selecting-fields) for `:hide` usage examples.
1138
-
1139
- ```ruby
1140
- class UserSerializer < Serega
1141
- attribute :email, if: :active? # translates to `if user.active?`
1142
- attribute :email, if: proc {|user| user.active?} # same
1143
- attribute :email, if: proc {|user, ctx| user == ctx[:current_user]}
1144
- attribute :email, if: CustomPolicy.method(:view_email?)
1145
-
1146
- attribute :email, unless: :hidden? # translates to `unless user.hidden?`
1147
- attribute :email, unless: proc {|user| user.hidden?} # same
1148
- attribute :email, unless: proc {|user, context| context[:show_emails]}
1149
- attribute :email, unless: CustomPolicy.method(:hide_email?)
1150
-
1151
- attribute :email, if_value: :present? # if email.present?
1152
- attribute :email, if_value: proc {|email| email.present?} # same
1153
- attribute :email, if_value: proc {|email, ctx| ctx[:show_emails]}
1154
- attribute :email, if_value: CustomPolicy.method(:view_email?)
1155
-
1156
- attribute :email, unless_value: :blank? # unless email.blank?
1157
- attribute :email, unless_value: proc {|email| email.blank?} # same
1158
- attribute :email, unless_value: proc {|email, context| context[:show_emails]}
1159
- attribute :email, unless_value: CustomPolicy.method(:hide_email?)
1160
- end
1161
- ```
1162
-
1163
- ### Plugin :camel_case
1164
-
1165
- Without this plugin, responding with *camelCased* keys means specifying the
1166
- attribute name and method directly for each attribute:
1167
- `attribute :firstName, method: first_name`
1168
-
1169
- This plugin camelCases every attribute name automatically, replacing `_x`
1170
- with `X` throughout the string. The transformation runs once, when the
1171
- attribute is defined, not on every serialization.
1172
-
1173
- Provide a custom transformation when adding the plugin, for example
1174
- `plugin :camel_case, transform: ->(name) { name.camelize }`
1175
-
1176
- Skip camelCase for a single attribute with `camel_case: false`.
1177
-
1178
- This plugin transforms only attribute keys, without affecting the `root`,
1179
- `metadata` and `context_metadata` plugins keys.
1180
-
1181
- If you wish to [select serialized fields](#selecting-fields), you should
1182
- provide them camelCased.
1183
-
1184
- ```ruby
1185
- class AppSerializer < Serega
1186
- plugin :camel_case
1187
- end
1188
-
1189
- class UserSerializer < AppSerializer
1190
- attribute :first_name
1191
- attribute :last_name
1192
- attribute :full_name, camel_case: false,
1193
- value: proc { |user| [user.first_name, user.last_name].compact.join(" ") }
178
+ make_shareable!
1194
179
  end
1195
180
 
1196
- require "ostruct"
1197
- user = OpenStruct.new(first_name: "Bruce", last_name: "Wayne")
1198
- UserSerializer.to_h(user)
1199
- # => {firstName: "Bruce", lastName: "Wayne", full_name: "Bruce Wayne"}
1200
-
1201
- UserSerializer.new(only: %i[firstName lastName]).to_h(user)
1202
- # => {firstName: "Bruce", lastName: "Wayne"}
181
+ Ractor.new(user) { |user| UserSerializer.to_h(user) }.value
1203
182
  ```
1204
183
 
1205
- ### Plugin :depth_limit
1206
-
1207
- Helps to secure from malicious queries that serialize too much
1208
- or from accidental serializing of objects with cyclic relations.
1209
-
1210
- Depth limit is checked when constructing a serialization plan, that is when
1211
- `#new` method is called, ex: `SomeSerializer.new(with: params[:with])`.
1212
- It can be useful to instantiate serializer before any other business logic
1213
- to get possible errors earlier.
1214
-
1215
- Any class-level serialization methods also check the depth limit as they also
1216
- instantiate serializer.
184
+ → [Ractors guide](docs/ractors.md)
1217
185
 
1218
- When the depth limit is exceeded `Serega::DepthLimitError` is raised.
1219
- Depth limit error details can be found in the additional
1220
- `Serega::DepthLimitError#details` method
186
+ ### And more
1221
187
 
1222
- The limit can be checked or changed with the next config options:
188
+ - [Attributes](docs/attributes.md) — all attribute options, relations and nested serializers
189
+ - [Conditional attributes](docs/conditional-attributes.md) — `if:`, `unless:`, `if_value:`, `unless_value:`
190
+ - [Context](docs/context.md) — pass `current_user` and other request data
191
+ - [Presenter](docs/presenter.md) — write computed attributes as methods
192
+ - [Prepare initial objects](docs/prepare-initial-objects.md) — serialize records by ids
193
+ - [Configuration](docs/configuration.md) — all config options
194
+ - [Root key and metadata](docs/root-and-metadata.md) — wrap results in `{data: ...}` and add metadata
1223
195
 
1224
- - `config.depth_limit.limit`
1225
- - `config.depth_limit.limit=`
196
+ ## Documentation
1226
197
 
1227
- There is no default limit, but it should be set when enabling the plugin.
198
+ - 📋 [Cheatsheet](CHEATSHEET.md) — every option on one page
199
+ - 📚 [Guides](docs/README.md) — each feature in depth
200
+ - 🔎 [Documentation site](https://andreyruby.github.io/serega/) — guides and API reference
201
+ - 🔤 [Type signatures](https://github.com/andreyruby/serega/tree/master/sig) — RBS of the public API
202
+ - 📝 [Changelog](https://github.com/andreyruby/serega/blob/master/CHANGELOG.md)
1228
203
 
1229
- ```ruby
1230
- class AppSerializer < Serega
1231
- plugin :depth_limit, limit: 10 # set limit for all child classes
1232
- end
1233
-
1234
- class UserSerializer < AppSerializer
1235
- config.depth_limit.limit = 5 # overrides limit for UserSerializer
1236
- end
1237
- ```
1238
-
1239
- ### Plugin :explicit_many_option
1240
-
1241
- Requires the `:many` option on every relationship attribute (an attribute
1242
- with the `:serializer` option or a block defining a nested serializer), so
1243
- it's always explicit whether it returns one object or many.
1244
-
1245
- ```ruby
1246
- class BaseSerializer < Serega
1247
- plugin :explicit_many_option
1248
- config.base_serializer = self
1249
- end
1250
-
1251
- class PostSerializer < BaseSerializer
1252
- attribute :text
1253
-
1254
- attribute :user, many: false do
1255
- attribute :name
1256
- end
204
+ ## Development
1257
205
 
1258
- attribute :comments, serializer: PostSerializer, many: true
1259
- end
206
+ ```sh
207
+ bundle install
208
+ bundle exec rake # tests and linter
209
+ bin/console # irb with serega loaded
1260
210
  ```
1261
211
 
1262
- ## Errors
1263
-
1264
- - The `Serega::SeregaError` is a base error raised by this gem.
1265
- - The `Serega::AttributeNotExist` error is raised when validating attributes in
1266
- `:only, :except, :with` modifiers. This error contains additional methods:
1267
-
1268
- - `#serializer` - shows current serializer
1269
- - `#attributes` - lists not existing attributes
1270
-
1271
- ## Release
1272
-
1273
- To release a new version, read [RELEASE.md](https://github.com/aglushkov/serega/blob/master/RELEASE.md).
1274
-
1275
- ## Development
1276
-
1277
- - `bundle install` - install dependencies
1278
- - `bin/console` - open irb console with loaded gems
1279
- - `bundle exec rspec` - run tests
1280
- - `bundle exec rubocop` - check code standards
1281
- - `yard stats --list-undoc --no-cache` - view undocumented code
1282
- - `yard server --reload` - view code documentation
212
+ Releasing: [RELEASE.md](https://github.com/andreyruby/serega/blob/master/RELEASE.md).
1283
213
 
1284
214
  ## Contributing
1285
215
 
1286
- Bug reports, pull requests and improvements ideas are very welcome!
216
+ Bug reports, pull requests and ideas are welcome!
1287
217
 
1288
218
  ## License
1289
219
 
1290
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
220
+ The gem is available as open source under the terms of the
221
+ [MIT License](https://opensource.org/licenses/MIT).
1291
222
 
1292
- [activerecord_preloads]: #plugin-activerecord_preloads
1293
- [batch-loading]: #batch-loading
1294
- [camel_case]: #plugin-camel_case
1295
- [context_metadata]: #plugin-context_metadata
1296
- [depth_limit]: #plugin-depth_limit
1297
- [formatters]: #plugin-formatters
1298
- [hash_access]: #serializing-hash-records
1299
- [metadata]: #plugin-metadata
1300
- [preloads]: #preloads
1301
- [presenter]: #plugin-presenter
1302
- [root]: #plugin-root
1303
- [string_modifiers]: #plugin-string_modifiers
1304
- [if]: #plugin-if
1305
- [build-badge]: https://github.com/aglushkov/serega/actions/workflows/main.yml/badge.svg?event=push
1306
- [build]: https://github.com/aglushkov/serega/actions/workflows/main.yml
223
+ [build-badge]: https://github.com/andreyruby/serega/actions/workflows/main.yml/badge.svg?event=push
224
+ [build]: https://github.com/andreyruby/serega/actions/workflows/main.yml