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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LeanStruct
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []