lean_struct 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +271 -0
- data/lib/lean_struct/class_methods.rb +41 -0
- data/lib/lean_struct/filtered_inspect.rb +22 -0
- data/lib/lean_struct/instance_methods.rb +25 -0
- data/lib/lean_struct/schema.rb +43 -0
- data/lib/lean_struct/version.rb +5 -0
- data/lib/lean_struct.rb +126 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c69420c8618e8f1c8790b3923ec0fbc4d48d1ccb31f79e9698f6d42a11956c84
|
|
4
|
+
data.tar.gz: 82c6ef9e2c793451238f9d5b18ddda1585133df61dc08cf192596e351c51e504
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1d01b4790df22732776874ef6a11a752193322e5e4b534467f84e1a56eb15176e5a5d61c7a20612076f7ac9c769804b08c53db4ddeedc3ad389289fc025ad14c
|
|
7
|
+
data.tar.gz: d1aa29de2d9d1c397319a264498e361dda9029130369bef2d1cb8d8f0f8b034be332bb34d35a26bc0b8e5dd451bbcfd13b066f516d3cdc53819a64cffc953737
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-09-30
|
|
6
|
+
|
|
7
|
+
- Initial release: `LeanStruct.define` DSL with `attribute` / `attribute?`, `nullable:`, nested structs,
|
|
8
|
+
arrays of nested structs, `class_name:`, `sensitive:`, `LeanStruct::Bool`, immutable instances, recursive `to_h` / `to_hash`.
|
|
9
|
+
- Errors for non-Hash input show only the input's class, not its value.
|
|
10
|
+
|
|
11
|
+
[Unreleased]: https://github.com/Ripchanskiy/lean_struct/compare/v0.1.0...HEAD
|
|
12
|
+
[0.1.0]: https://github.com/Ripchanskiy/lean_struct/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Serhii Ripchanskyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# LeanStruct
|
|
2
|
+
|
|
3
|
+
Immutable, type-checked `Struct` classes with a tiny DSL and **zero runtime dependencies**.
|
|
4
|
+
|
|
5
|
+
LeanStruct covers the part of [Dry::Struct](https://dry-rb.org/gems/dry-struct/) most apps use for
|
|
6
|
+
simple entities: strict type checks, optional and nullable attributes, nested structs, arrays of
|
|
7
|
+
nested structs and a recursive `to_h`. The result is a plain Ruby `Struct`, roughly 2–3× faster than
|
|
8
|
+
Dry::Struct (see [Performance](#performance)).
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
User = LeanStruct.define do
|
|
12
|
+
attribute :id, Integer
|
|
13
|
+
attribute :name, String, nullable: true # key required, value may be nil
|
|
14
|
+
attribute? :email, String # key may be omitted
|
|
15
|
+
attribute :active, LeanStruct::Bool
|
|
16
|
+
attribute :address do # nested struct => User::Address
|
|
17
|
+
attribute :city, String
|
|
18
|
+
end
|
|
19
|
+
attribute :roles, Array do # array of nested structs => User::Roles
|
|
20
|
+
attribute :code, String
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
user = User.new(id: 1, name: nil, active: true, address: { city: "Kyiv" }, roles: [{ code: "admin" }])
|
|
25
|
+
user.address.city # => "Kyiv"
|
|
26
|
+
user.to_h
|
|
27
|
+
# => { id: 1, name: nil, active: true, address: { city: "Kyiv" }, roles: [{ code: "admin" }] }
|
|
28
|
+
|
|
29
|
+
User.new(id: "1", name: nil, active: true, address: { city: "Kyiv" }, roles: [])
|
|
30
|
+
# => LeanStruct::Error: [User.new] "1" (String) has invalid type for :id
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Gemfile
|
|
37
|
+
gem "lean_struct"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Ruby 3.1+.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Defining attributes
|
|
45
|
+
|
|
46
|
+
| DSL | key | `nil` |
|
|
47
|
+
|----------------------------------------------|------------|-----------|
|
|
48
|
+
| `attribute :code, String` | required | rejected |
|
|
49
|
+
| `attribute :code, String, nullable: true` | required | accepted |
|
|
50
|
+
| `attribute? :code, String` | may be omitted | rejected |
|
|
51
|
+
| `attribute? :code, String, nullable: true` | may be omitted | accepted |
|
|
52
|
+
|
|
53
|
+
Any attribute also takes `sensitive: true` to hide its value in errors and `#inspect`
|
|
54
|
+
(see [Sensitive attributes](#sensitive-attributes)).
|
|
55
|
+
|
|
56
|
+
`attribute?` only allows the key to be **left out**. It does not allow `nil`. Use `nullable: true` for that.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Tag = LeanStruct.define { attribute? :code, String }
|
|
60
|
+
|
|
61
|
+
Tag.new({}) # => ok, to_h == {}
|
|
62
|
+
Tag.new(code: "a") # => ok
|
|
63
|
+
Tag.new(code: nil) # => LeanStruct::Error
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Types
|
|
67
|
+
|
|
68
|
+
A type is anything that responds to `===`, checked as `type === value`. No coercion is done: `"1"` is not an `Integer`.
|
|
69
|
+
Any class works: `String`, `Float`, `Symbol`, `Hash`, `Time`, your own classes.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
LeanStruct.define do
|
|
73
|
+
attribute :id, Integer # class: value.is_a?(Integer)
|
|
74
|
+
attribute :active, LeanStruct::Bool # true or false only
|
|
75
|
+
attribute :age, 0..150 # Range
|
|
76
|
+
attribute :country, /\A[A-Z]{2}\z/ # Regexp
|
|
77
|
+
attribute :even, ->(value) { value.is_a?(Integer) && value.even? } # lambda
|
|
78
|
+
attribute :tags, Array # any Array, items are not checked
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Nested structs
|
|
83
|
+
|
|
84
|
+
A block defines a nested struct. The nested class is named after the attribute (`User::Address`),
|
|
85
|
+
or after `class_name:` when given:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Order = LeanStruct.define do
|
|
89
|
+
attribute :customer do
|
|
90
|
+
attribute :name, String
|
|
91
|
+
end
|
|
92
|
+
attribute :items, Array, class_name: "Item" do # Array + block = array of nested structs
|
|
93
|
+
attribute :sku, String
|
|
94
|
+
attribute :quantity, Integer
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
order = Order.new(customer: { name: "Ann" }, items: [{ sku: "A-1", quantity: 2 }])
|
|
99
|
+
order.items.first # => #<struct Order::Item sku="A-1", quantity=2>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- A nested value may be a `Hash` or an already built instance of the nested class.
|
|
103
|
+
- `nil` for a nested attribute is treated as `{}`. This is valid only when every nested key is optional (`attribute?`).
|
|
104
|
+
Use `nullable: true` to keep `nil` as is.
|
|
105
|
+
|
|
106
|
+
### Input rules
|
|
107
|
+
|
|
108
|
+
- Keys must be **symbols**. `{ "id" => 1 }` fails with `:id is missing in Hash input`.
|
|
109
|
+
- **Unknown keys are ignored.**
|
|
110
|
+
- Both a hash and keyword arguments work: `User.new(hash)`, `User.new(**hash)`, `User.call(hash)`, `User[hash]`.
|
|
111
|
+
- `User.new(user)` returns the same `user` object, so `.call` is safe in pipelines that may already hold an entity.
|
|
112
|
+
|
|
113
|
+
### Reading
|
|
114
|
+
|
|
115
|
+
Instances are regular `Struct`s: readers, `[]`, `==`, `members`, `to_a`, `each`, `deconstruct` and
|
|
116
|
+
`deconstruct_keys` (pattern matching) all work.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
case user
|
|
120
|
+
in { id: Integer => id, address: { city: } } then [id, city]
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`to_h` (aliases `to_hash` and `attributes`) is recursive and returns keys in declaration order.
|
|
125
|
+
**Keys omitted on input are left out**, so "not given" and "given as `nil`" stay distinguishable:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Tag.new({}).to_h # => {}
|
|
129
|
+
{ **user } # works thanks to to_hash
|
|
130
|
+
user.to_h { |k, v| [k.to_s, v] } # block form, like Hash#to_h
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Immutability
|
|
134
|
+
|
|
135
|
+
Writers are removed: `user.id = 2` and `user[:id] = 2` raise `NoMethodError`.
|
|
136
|
+
|
|
137
|
+
Immutability is shallow, as in Dry::Struct. Neither instances nor their values are frozen:
|
|
138
|
+
|
|
139
|
+
- `user.tags << "admin"` changes the struct (so does `user.name << "x"`, if the string is not frozen);
|
|
140
|
+
- a plain `Array` attribute keeps the caller's array, so changing it outside changes the struct too;
|
|
141
|
+
- arrays of nested structs are built anew, but you can still append items to them.
|
|
142
|
+
|
|
143
|
+
If you need deep immutability, freeze the input data before building the struct.
|
|
144
|
+
|
|
145
|
+
### Errors
|
|
146
|
+
|
|
147
|
+
Every validation failure raises `LeanStruct::Error`, a subclass of `ArgumentError`:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
[User.new] :id is missing in Hash input
|
|
151
|
+
[User.new] "1" (String) has invalid type for :id
|
|
152
|
+
[User.new] Array has invalid type (Hash expected)
|
|
153
|
+
[User::Address.new] 1 (Integer) has invalid type for :city
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Messages include the `inspect` of the rejected value, which helps when an upstream API changes a type.
|
|
157
|
+
Two exceptions keep data out of logs and error trackers:
|
|
158
|
+
|
|
159
|
+
- When the input is not a Hash, only its class is shown, so `Config.new(ENV)` or a raw HTTP response never
|
|
160
|
+
ends up in the message.
|
|
161
|
+
- Values of `sensitive: true` attributes are replaced with `[FILTERED]` (see below).
|
|
162
|
+
|
|
163
|
+
### Sensitive attributes
|
|
164
|
+
|
|
165
|
+
Mark secrets with `sensitive: true`, which is useful for configs built from ENV or YAML:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
Config = LeanStruct.define do
|
|
169
|
+
attribute :host, String
|
|
170
|
+
attribute :password, String, sensitive: true
|
|
171
|
+
attribute :api_key, /\A[a-f0-9]{32}\z/, sensitive: true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Config.new(host: "db", password: 123456, api_key: key)
|
|
175
|
+
# => LeanStruct::Error: [Config.new] [FILTERED] (Integer) has invalid type for :password
|
|
176
|
+
|
|
177
|
+
config = Config.new(host: "db", password: "s3cret", api_key: key)
|
|
178
|
+
config.inspect # => #<struct Config host="db", password=[FILTERED], api_key=[FILTERED]>
|
|
179
|
+
config.password # => "s3cret"
|
|
180
|
+
config.to_h # => { host: "db", password: "s3cret", api_key: "..." }
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- The value is hidden in error messages, `#inspect`, `#to_s` and `pp`. The class is still shown in errors,
|
|
184
|
+
so you can see that YAML turned `123456` into an `Integer`.
|
|
185
|
+
- `nil` is shown as is: "not set" is not a secret.
|
|
186
|
+
- Readers and `#to_h` return real values: `to_h` is data, not a log line.
|
|
187
|
+
- It applies to the attribute's own value. For a nested struct, mark the nested attributes themselves.
|
|
188
|
+
- Structs without sensitive attributes keep the standard `Struct#inspect`.
|
|
189
|
+
|
|
190
|
+
### Class API
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
User.attribute_names # => [:id, :name, :email, :active, :address, :roles]
|
|
194
|
+
User.schema[:email] # => #<struct LeanStruct::Field name=:email, type=String, nullable=false, omittable=true, ...>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Comparison with Dry::Struct
|
|
198
|
+
|
|
199
|
+
### DSL
|
|
200
|
+
|
|
201
|
+
| Dry::Struct | LeanStruct |
|
|
202
|
+
|---------------------------------------------------|---------------------------------------------------|
|
|
203
|
+
| `class User < Dry::Struct` | `User = LeanStruct.define do ... end` |
|
|
204
|
+
| `attribute :id, Types::Integer` | `attribute :id, Integer` |
|
|
205
|
+
| `attribute :name, Types::String.optional` | `attribute :name, String, nullable: true` |
|
|
206
|
+
| `attribute? :code, Types::String` | `attribute? :code, String` |
|
|
207
|
+
| `attribute? :code, Types::String.optional` | `attribute? :code, String, nullable: true` |
|
|
208
|
+
| `attribute :active, Types::Bool` | `attribute :active, LeanStruct::Bool` |
|
|
209
|
+
| `attribute :address do ... end` | `attribute :address do ... end` |
|
|
210
|
+
| `attribute :roles, Types::Array do ... end` | `attribute :roles, Array do ... end` |
|
|
211
|
+
|
|
212
|
+
### Same behaviour
|
|
213
|
+
|
|
214
|
+
Compared with Dry::Struct using the strict `Dry.Types()` types:
|
|
215
|
+
|
|
216
|
+
| input | result (both) |
|
|
217
|
+
|-------------------------------------------------|---------------------------------------|
|
|
218
|
+
| unknown key | ignored |
|
|
219
|
+
| string keys | error: key is missing |
|
|
220
|
+
| missing required key | error |
|
|
221
|
+
| omitted `attribute?` | ok, key left out of `to_h` |
|
|
222
|
+
| `nil` for a non-optional / non-nullable key | error |
|
|
223
|
+
| `"1"` for an `Integer` attribute | error, no coercion |
|
|
224
|
+
| `nil` for a nested struct with only `attribute?` keys | `{}` |
|
|
225
|
+
| non-array for an array attribute | error |
|
|
226
|
+
| input is not a Hash | error |
|
|
227
|
+
| `Klass.new(instance)` | returns the same instance |
|
|
228
|
+
| `to_h` / `to_hash` | recursive hash |
|
|
229
|
+
| writers | none |
|
|
230
|
+
| `==` | by value |
|
|
231
|
+
|
|
232
|
+
### Differences
|
|
233
|
+
|
|
234
|
+
| aspect | Dry::Struct | LeanStruct |
|
|
235
|
+
|---------------------------------|------------------------------------------------------|---------------------------------------------|
|
|
236
|
+
| base class | `Dry::Struct` | Ruby `Struct` |
|
|
237
|
+
| dependencies | dry-struct, dry-types, dry-core, dry-logic, dry-inflector, ... | none |
|
|
238
|
+
| error class | `Dry::Struct::Error` | `LeanStruct::Error < ArgumentError` |
|
|
239
|
+
| error message | `... has invalid type for :id violates constraints (type?(Integer, "1") failed)` | `... has invalid type for :id` |
|
|
240
|
+
| secrets in errors / `#inspect` | shown | hidden with `sensitive: true`; non-Hash input shows only its class |
|
|
241
|
+
| array item class name | singularized: `roles` → `User::Role` | as the attribute: `User::Roles`, or `class_name:` |
|
|
242
|
+
| `Struct` API (`to_a`, `each`, `members`) | no (`attribute_names`, `schema`) | yes, plus `attribute_names` and `schema` |
|
|
243
|
+
| `entity.new(changes)` (copy with changes) | yes | no |
|
|
244
|
+
| defaults, coercion, constraints, `transform_keys`, custom constructors | yes | no |
|
|
245
|
+
| inheritance (`class Admin < User`) | yes | no |
|
|
246
|
+
|
|
247
|
+
If you need coercion, defaults or composable types, use Dry::Struct. If your entities are
|
|
248
|
+
"validate a hash from an API and serialize it back", LeanStruct does that with less overhead.
|
|
249
|
+
|
|
250
|
+
### Performance
|
|
251
|
+
|
|
252
|
+
Ruby 3.4.10, dry-struct 1.8.1, Apple Silicon, 50 000 iterations, median of 5 runs.
|
|
253
|
+
*Flat*: 5 scalar attributes, one of them omitted. *Nested*: 3 nested structs, an array of 2 nested structs and a bool.
|
|
254
|
+
|
|
255
|
+
| operation | Dry::Struct, ms | LeanStruct, ms | LeanStruct faster by |
|
|
256
|
+
|-----------------|----------------:|---------------:|---------------------:|
|
|
257
|
+
| flat `.new` | 135.2 | 59.1 | 2.3× |
|
|
258
|
+
| flat `#to_h` | 96.5 | 44.0 | 2.2× |
|
|
259
|
+
| nested `.new` | 511.1 | 274.0 | 1.9× |
|
|
260
|
+
| nested `#to_h` | 490.8 | 166.1 | 3.0× |
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
bundle install
|
|
266
|
+
bundle exec rake # specs + rubocop
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
[MIT](LICENSE.txt)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanStruct
|
|
4
|
+
# Prepended to the struct's singleton class, so it wraps Struct's own .new and .[]
|
|
5
|
+
module ClassMethods
|
|
6
|
+
attr_reader :schema
|
|
7
|
+
|
|
8
|
+
# Kept in one piece on purpose: hot path, no extra method calls per field
|
|
9
|
+
def new(attrs = EMPTY_HASH) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
10
|
+
return attrs if attrs.is_a?(self)
|
|
11
|
+
|
|
12
|
+
LeanStruct.not_hash!(self, attrs) unless attrs.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
omitted = 0 # bitmask of omitted keys: no allocation per instance
|
|
15
|
+
bit = 1
|
|
16
|
+
values = @fields.map do |field|
|
|
17
|
+
value = attrs.fetch(field.name, UNDEFINED)
|
|
18
|
+
if UNDEFINED.equal?(value)
|
|
19
|
+
LeanStruct.missing!(self, field) unless field.omittable
|
|
20
|
+
omitted |= bit
|
|
21
|
+
value = nil
|
|
22
|
+
elsif field.many || !(field.type === value) # rubocop:disable Style/CaseEquality
|
|
23
|
+
value = LeanStruct.cast(self, field, value)
|
|
24
|
+
end
|
|
25
|
+
bit <<= 1
|
|
26
|
+
value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
instance = super(*values)
|
|
30
|
+
instance.instance_variable_set(:@__omitted__, omitted) unless omitted.zero?
|
|
31
|
+
instance
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
alias call new
|
|
35
|
+
alias [] new
|
|
36
|
+
|
|
37
|
+
def attribute_names
|
|
38
|
+
members
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanStruct
|
|
4
|
+
# Included only into structs that have sensitive: true attributes.
|
|
5
|
+
# Masks their values in #inspect, #to_s and pp (nil stays visible: "not set" is not a secret).
|
|
6
|
+
# #to_h and readers still return real values.
|
|
7
|
+
module FilteredInspect
|
|
8
|
+
def inspect
|
|
9
|
+
schema = self.class.schema
|
|
10
|
+
pairs = each_pair.map do |name, value|
|
|
11
|
+
"#{name}=#{LeanStruct.filter(schema[name], value)}"
|
|
12
|
+
end
|
|
13
|
+
["#<struct", self.class.name, pairs.join(", ")].compact.join(" ").concat(">")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
alias to_s inspect
|
|
17
|
+
|
|
18
|
+
def pretty_print(printer)
|
|
19
|
+
printer.text(inspect)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanStruct
|
|
4
|
+
# Included (not prepended), so attribute readers with the same names still win
|
|
5
|
+
module InstanceMethods
|
|
6
|
+
# Recursive hash; keys omitted on input are left out
|
|
7
|
+
def to_h(&block) # rubocop:disable Metrics/MethodLength
|
|
8
|
+
omitted = @__omitted__
|
|
9
|
+
hash = {}
|
|
10
|
+
if omitted
|
|
11
|
+
bit = 1
|
|
12
|
+
each_pair do |name, value|
|
|
13
|
+
hash[name] = LeanStruct.dump(value) if omitted.nobits?(bit)
|
|
14
|
+
bit <<= 1
|
|
15
|
+
end
|
|
16
|
+
else
|
|
17
|
+
each_pair { |name, value| hash[name] = LeanStruct.dump(value) }
|
|
18
|
+
end
|
|
19
|
+
block ? hash.to_h(&block) : hash
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
alias to_hash to_h
|
|
23
|
+
alias attributes to_h
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanStruct
|
|
4
|
+
# DSL evaluated inside LeanStruct.define
|
|
5
|
+
class Schema
|
|
6
|
+
def self.build(&)
|
|
7
|
+
schema = new
|
|
8
|
+
schema.instance_eval(&)
|
|
9
|
+
schema.fields
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :fields
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@fields = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# attribute :id, Integer
|
|
19
|
+
# attribute :name, String, nullable: true
|
|
20
|
+
# attribute :address do ... end
|
|
21
|
+
# attribute :roles, Array, class_name: "Role" do ... end
|
|
22
|
+
# attribute :token, String, sensitive: true
|
|
23
|
+
def attribute(name, type = nil, nullable: false, omittable: false, class_name: nil, sensitive: false, &block) # rubocop:disable Metrics/ParameterLists
|
|
24
|
+
name = name.to_sym
|
|
25
|
+
validate!(name, type, block)
|
|
26
|
+
nested = LeanStruct.define(&block) if block
|
|
27
|
+
@fields << Field.new(name:, type: nested || type, nullable:, omittable:, class_name:, sensitive:,
|
|
28
|
+
many: !nested.nil? && type == Array, nested: !nested.nil?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Same as #attribute, but the key may be omitted from input
|
|
32
|
+
def attribute?(name, type = nil, **opts, &)
|
|
33
|
+
attribute(name, type, **opts, omittable: true, &)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate!(name, type, block)
|
|
39
|
+
raise Error, "attribute :#{name} is already defined" if @fields.any? { |field| field.name == name }
|
|
40
|
+
raise Error, "attribute :#{name} requires a type or a block" if type.nil? && block.nil?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/lean_struct.rb
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lean_struct/version"
|
|
4
|
+
require_relative "lean_struct/schema"
|
|
5
|
+
require_relative "lean_struct/class_methods"
|
|
6
|
+
require_relative "lean_struct/instance_methods"
|
|
7
|
+
require_relative "lean_struct/filtered_inspect"
|
|
8
|
+
|
|
9
|
+
# Builds immutable Struct classes with type checks on construction.
|
|
10
|
+
#
|
|
11
|
+
# User = LeanStruct.define do
|
|
12
|
+
# attribute :id, Integer
|
|
13
|
+
# attribute :name, String, nullable: true # key required, value may be nil
|
|
14
|
+
# attribute? :email, String # key may be omitted
|
|
15
|
+
# attribute :token, String, sensitive: true # value hidden in errors and #inspect
|
|
16
|
+
# attribute :address do # nested struct => User::Address
|
|
17
|
+
# attribute :city, String
|
|
18
|
+
# end
|
|
19
|
+
# attribute :roles, Array do # array of nested structs => User::Roles
|
|
20
|
+
# attribute :code, String
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# User.new(id: 1, name: nil, address: { city: "Kyiv" }, roles: [])
|
|
25
|
+
module LeanStruct
|
|
26
|
+
class Error < ArgumentError; end
|
|
27
|
+
|
|
28
|
+
# Type matcher for true/false (Ruby has no Boolean class)
|
|
29
|
+
module Bool
|
|
30
|
+
def self.===(value)
|
|
31
|
+
value.equal?(true) || value.equal?(false)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Field = Struct.new(:name, :type, :nullable, :omittable, :many, :nested, :class_name, :sensitive,
|
|
36
|
+
keyword_init: true)
|
|
37
|
+
|
|
38
|
+
FILTERED = "[FILTERED]"
|
|
39
|
+
EMPTY_HASH = {}.freeze
|
|
40
|
+
UNDEFINED = Object.new.freeze
|
|
41
|
+
private_constant :UNDEFINED
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def define(&block) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
45
|
+
raise Error, "LeanStruct.define requires a block" unless block
|
|
46
|
+
|
|
47
|
+
fields = Schema.build(&block)
|
|
48
|
+
raise Error, "LeanStruct.define requires at least one attribute" if fields.empty?
|
|
49
|
+
|
|
50
|
+
klass = Struct.new(*fields.map(&:name), keyword_init: false)
|
|
51
|
+
klass.instance_variable_set(:@fields, fields.freeze)
|
|
52
|
+
klass.instance_variable_set(:@schema, fields.to_h { |field| [field.name, field] }.freeze)
|
|
53
|
+
name_nested(klass, fields)
|
|
54
|
+
make_immutable(klass, fields)
|
|
55
|
+
klass.singleton_class.prepend(ClassMethods)
|
|
56
|
+
klass.include(InstanceMethods)
|
|
57
|
+
klass.include(FilteredInspect) if fields.any?(&:sensitive)
|
|
58
|
+
klass
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @api private
|
|
62
|
+
def cast(klass, field, value)
|
|
63
|
+
return value if value.nil? && field.nullable
|
|
64
|
+
return cast_one(klass, field, value) unless field.many
|
|
65
|
+
return value.map { |item| cast_one(klass, field, item) } if value.is_a?(Array)
|
|
66
|
+
|
|
67
|
+
invalid!(klass, field, value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @api private
|
|
71
|
+
def dump(value)
|
|
72
|
+
case value
|
|
73
|
+
when Struct then value.to_h
|
|
74
|
+
when Array then value.map { |item| dump(item) }
|
|
75
|
+
else value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
def missing!(klass, field)
|
|
81
|
+
raise Error, "[#{klass}.new] :#{field.name} is missing in Hash input"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @api private
|
|
85
|
+
def invalid!(klass, field, value)
|
|
86
|
+
raise Error, "[#{klass}.new] #{filter(field, value)} (#{value.class}) has invalid type for :#{field.name}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @api private
|
|
90
|
+
def filter(field, value)
|
|
91
|
+
field.sensitive && !value.nil? ? FILTERED : value.inspect
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @api private
|
|
95
|
+
# Only the class: the whole input (e.g. ENV) must never end up in the message
|
|
96
|
+
def not_hash!(klass, value)
|
|
97
|
+
raise Error, "[#{klass}.new] #{value.class} has invalid type (Hash expected)"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def cast_one(klass, field, value)
|
|
103
|
+
return value if field.type === value # rubocop:disable Style/CaseEquality
|
|
104
|
+
# nil nested input is treated as {} (valid when all nested keys are omittable)
|
|
105
|
+
return field.type.new(value || EMPTY_HASH) if field.nested && (value.nil? || value.is_a?(Hash))
|
|
106
|
+
|
|
107
|
+
invalid!(klass, field, value)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# User::Address, User::Roles, or the explicit class_name: option
|
|
111
|
+
def name_nested(klass, fields)
|
|
112
|
+
fields.select(&:nested).each do |field|
|
|
113
|
+
klass.const_set(field.class_name || camelize(field.name), field.type)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def camelize(name)
|
|
118
|
+
name.to_s.split("_").map(&:capitalize).join
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def make_immutable(klass, fields)
|
|
122
|
+
klass.send(:undef_method, :[]=)
|
|
123
|
+
fields.each { |field| klass.send(:undef_method, :"#{field.name}=") }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lean_struct
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Serhii Ripchanskyi
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: LeanStruct builds plain Ruby Struct classes with type checks, optional
|
|
13
|
+
and nullable attributes, nested structs and recursive to_h. A lightweight, dependency-free
|
|
14
|
+
alternative to Dry::Struct for simple entities.
|
|
15
|
+
email:
|
|
16
|
+
- sergeyripchanskiy@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/lean_struct.rb
|
|
25
|
+
- lib/lean_struct/class_methods.rb
|
|
26
|
+
- lib/lean_struct/filtered_inspect.rb
|
|
27
|
+
- lib/lean_struct/instance_methods.rb
|
|
28
|
+
- lib/lean_struct/schema.rb
|
|
29
|
+
- lib/lean_struct/version.rb
|
|
30
|
+
homepage: https://github.com/Ripchanskiy/lean_struct
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
allowed_push_host: https://rubygems.org
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
source_code_uri: https://github.com/Ripchanskiy/lean_struct
|
|
37
|
+
changelog_uri: https://github.com/Ripchanskiy/lean_struct/blob/main/CHANGELOG.md
|
|
38
|
+
bug_tracker_uri: https://github.com/Ripchanskiy/lean_struct/issues
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.7.2
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: Immutable, type-checked Struct classes with a small DSL and zero dependencies.
|
|
56
|
+
test_files: []
|