philiprehberger-struct_kit 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b55b47cb37e7ebf99690da52df447bbd35570f3d025047fd37bb5be1f15cbb0
4
- data.tar.gz: 03a1b60f04e75e455389020bbdf34d2ec028651fe7c14abeb0038a6c33937068
3
+ metadata.gz: fb334ab4e4fec14463ca38a07eecad0e085d8b9abf0d8414d0efd8a525040bdc
4
+ data.tar.gz: 026bf5453a47902dd2aaf27bb903c0a23d514f4d25ef8f76ea59c0c7248bc61f
5
5
  SHA512:
6
- metadata.gz: 8efe14b43443a8ce8fca842f2ae3ae70a8cb511c753319883fba1454c0fd45f8839945ebfebd282242291b51ea28148ef213756c8ca6cbb55e142c51d3ef7851
7
- data.tar.gz: 668b1b75f73a814b437c6647019afbbbc78e566817de8f11a650a7cb8fa6ef7bab695e0417f0b0255dc7175cb2b1b118d6fdef9f507b971e67ea7f157408966d
6
+ metadata.gz: 68319362763b7797c3805a41a425cdf7b4a534382f8c48911fb80021c5b88775fb5e66b8a7400dcf0b41cfbf6575d22e33cc784cebb51040893e5e58e7f60c58
7
+ data.tar.gz: 615483affddbec4813f6f794edf904a5c5c4e04d70f66d0cd19703edd59239e5ac7f9424a9c7d247b298fe7c7a7f1a5075d4af757fe93bdd3719796d09b5f70c
data/CHANGELOG.md CHANGED
@@ -2,21 +2,39 @@
2
2
 
3
3
  All notable changes to this gem will be documented in this file.
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.0] - 2026-03-21
10
+ ## [0.1.2] - 2026-03-22
11
+
12
+ ### Fixed
13
+
14
+ - Fix CHANGELOG header wording
15
+ - Add bug_tracker_uri to gemspec
16
+
17
+ ## [0.1.1] - 2026-03-22
18
+
19
+ ### Changed
20
+ - Improve source code, tests, and rubocop compliance
21
+
22
+ ## [0.1.0] - 2026-03-22
11
23
 
12
24
  ### Added
25
+
13
26
  - Initial release
14
27
  - DSL-based struct definition with `StructKit.define`
15
- - Typed fields with runtime type checking
28
+ - Typed fields with runtime type checking (single class or array of classes)
16
29
  - Default values (static and lambda)
17
- - Value coercion via `coerce:` option
18
- - Validation rules (range, format, custom blocks)
19
- - Immutable instances (frozen by default)
20
- - `#to_h` and `.from_h` for hash serialization
21
- - `#merge` for creating modified copies
22
- - Pattern matching support via `deconstruct_keys`
30
+ - Validation rules (range, format regex, custom blocks)
31
+ - Immutable instances by default (frozen after initialization)
32
+ - Mutable variant with `StructKit.define(mutable: true)`
33
+ - `#to_h` and `.from_h` for hash serialization (string keys accepted)
34
+ - `#to_json` for JSON serialization
35
+ - Pattern matching support via `#deconstruct_keys`
36
+ - Value equality via `#==`
37
+ - Keyword-only constructor
38
+
39
+ [Unreleased]: https://github.com/philiprehberger/rb-struct-kit/compare/v0.1.0...HEAD
40
+ [0.1.0]: https://github.com/philiprehberger/rb-struct-kit/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # philiprehberger-struct_kit
2
2
 
3
- [![Tests](https://github.com/philiprehberger/rb-struct-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-struct-kit/actions/workflows/ci.yml)
4
- [![Gem Version](https://badge.fury.io/rb/philiprehberger-struct_kit.svg)](https://rubygems.org/gems/philiprehberger-struct_kit)
5
- [![License](https://img.shields.io/github/license/philiprehberger/rb-struct-kit)](LICENSE)
3
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-struct_kit.svg)](https://badge.fury.io/rb/philiprehberger-struct_kit)
4
+ [![CI](https://github.com/philiprehberger/rb-struct-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-struct-kit/actions/workflows/ci.yml)
6
5
 
7
- Enhanced struct builder with typed fields, defaults, validation, and pattern matching
6
+ Enhanced struct builder with typed fields, defaults, validation, and pattern matching.
8
7
 
9
8
  ## Requirements
10
9
 
@@ -20,7 +19,7 @@ gem 'philiprehberger-struct_kit'
20
19
 
21
20
  Or install directly:
22
21
 
23
- ```bash
22
+ ```sh
24
23
  gem install philiprehberger-struct_kit
25
24
  ```
26
25
 
@@ -39,66 +38,67 @@ end
39
38
  user = User.new(name: 'Alice', age: 30)
40
39
  user.name # => "Alice"
41
40
  user.age # => 30
42
- user.role # => :user
41
+ user.frozen? # => true
43
42
  ```
44
43
 
45
- ### Defaults
44
+ ### Type Checking
46
45
 
47
46
  ```ruby
48
- Config = Philiprehberger::StructKit.define do
49
- field :timeout, Integer, default: 30
50
- field :tags, Array, default: -> { [] }
47
+ Point = Philiprehberger::StructKit.define do
48
+ field :x, Integer
49
+ field :y, Integer
50
+ field :active, [TrueClass, FalseClass], default: true
51
51
  end
52
52
 
53
- config = Config.new
54
- config.timeout # => 30
55
- config.tags # => []
53
+ Point.new(x: 1, y: 2) # OK
54
+ Point.new(x: 'a', y: 2) # TypeError!
55
+ Point.new(x: 1, y: 2, active: 0) # TypeError!
56
56
  ```
57
57
 
58
- ### Type Coercion
58
+ ### Default Values
59
59
 
60
60
  ```ruby
61
- Record = Philiprehberger::StructKit.define do
62
- field :count, Integer, default: 0, coerce: ->(v) { v.to_i }
61
+ Config = Philiprehberger::StructKit.define do
62
+ field :timeout, Integer, default: 30
63
+ field :tags, Array, default: -> { [] } # lambda for mutable defaults
63
64
  end
64
-
65
- record = Record.new(count: '42')
66
- record.count # => 42
67
65
  ```
68
66
 
69
67
  ### Validation
70
68
 
71
69
  ```ruby
72
- user = User.new(name: 'Alice', age: 200)
73
- user.valid? # => false
74
- user.errors # => ["age must be in range 0..150"]
70
+ Email = Philiprehberger::StructKit.define do
71
+ field :address, String
72
+ validate :address, format: /@/
73
+ end
75
74
  ```
76
75
 
77
- ### Immutability
76
+ ### Mutable Structs
78
77
 
79
78
  ```ruby
80
- user = User.new(name: 'Alice')
81
- user.frozen? # => true
79
+ MutableUser = Philiprehberger::StructKit.define(mutable: true) do
80
+ field :name, String
81
+ field :age, Integer, default: 0
82
+ end
82
83
 
83
- updated = user.merge(age: 31) # returns new instance
84
- updated.age # => 31
85
- user.age # => 0 (unchanged)
84
+ user = MutableUser.new(name: 'Alice')
85
+ user.name = 'Bob' # OK, not frozen
86
86
  ```
87
87
 
88
- ### Hash Serialization
88
+ ### Serialization
89
89
 
90
90
  ```ruby
91
91
  user = User.new(name: 'Alice', age: 30)
92
- user.to_h # => { name: "Alice", age: 30, role: :user }
93
92
 
94
- User.from_h({ name: 'Bob', age: 25 })
93
+ user.to_h # => { name: "Alice", age: 30, role: :user }
94
+ user.to_json # => '{"name":"Alice","age":30,"role":"user"}'
95
+
96
+ User.from_h({ 'name' => 'Bob', 'age' => 25 }) # string keys OK
95
97
  ```
96
98
 
97
99
  ### Pattern Matching
98
100
 
99
101
  ```ruby
100
- user = User.new(name: 'Alice', role: :admin)
101
-
102
102
  case user
103
103
  in { role: :admin }
104
104
  puts 'Admin user'
@@ -109,43 +109,41 @@ end
109
109
 
110
110
  ## API
111
111
 
112
- ### `Philiprehberger::StructKit`
112
+ ### `Philiprehberger::StructKit.define(mutable: false, &block)`
113
113
 
114
- | Method | Description |
115
- |--------|-------------|
116
- | `.define { block }` | Define a new struct class with the DSL |
114
+ Define a new struct class. Evaluates the block in DSL context.
117
115
 
118
- ### DSL (inside `define` block)
116
+ ### DSL Methods
119
117
 
120
118
  | Method | Description |
121
119
  |--------|-------------|
122
- | `field :name, Type, default:, coerce:` | Define a typed field with optional default and coercion |
123
- | `validate :name, range:, format:` | Add validation rules to a field |
120
+ | `field(name, type = nil, default: UNSET)` | Declare a typed field with optional default |
121
+ | `validate(name, range: nil, format: nil, &block)` | Add validation rule to a field |
124
122
 
125
123
  ### Instance Methods
126
124
 
127
125
  | Method | Description |
128
126
  |--------|-------------|
129
127
  | `#to_h` | Convert to a plain hash |
130
- | `#merge(**attrs)` | Return a new instance with merged attributes |
131
- | `#valid?` | Whether all validations pass |
132
- | `#errors` | Array of validation error messages |
128
+ | `#to_json` | Convert to JSON string |
133
129
  | `#deconstruct_keys(keys)` | Pattern matching support |
130
+ | `#==` | Value equality |
131
+ | `#inspect` | Human-readable string representation |
134
132
 
135
133
  ### Class Methods
136
134
 
137
135
  | Method | Description |
138
136
  |--------|-------------|
139
- | `.from_h(hash)` | Construct from a hash (string or symbol keys) |
137
+ | `.from_h(hash)` | Construct from hash (string or symbol keys) |
140
138
 
141
139
  ## Development
142
140
 
143
- ```bash
141
+ ```sh
144
142
  bundle install
145
- bundle exec rspec # Run tests
146
- bundle exec rubocop # Check code style
143
+ bundle exec rspec
144
+ bundle exec rubocop
147
145
  ```
148
146
 
149
147
  ## License
150
148
 
151
- MIT
149
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -1,87 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'field'
4
- require_relative 'validation'
5
4
 
6
5
  module Philiprehberger
7
6
  module StructKit
8
7
  class Definition
9
- def initialize
8
+ def initialize(mutable: false)
10
9
  @fields = {}
11
10
  @validations = {}
11
+ @mutable = mutable
12
12
  end
13
13
 
14
- def field(name, type = nil, default: :__no_default__, coerce: nil)
15
- @fields[name] = Field.new(name, type, default: default, coerce: coerce)
14
+ def field(name, type = nil, default: Field::UNSET)
15
+ @fields[name] = Field.new(name, type, default: default)
16
16
  end
17
17
 
18
- def validate(name, **rules, &block)
19
- @validations[name] ||= []
20
- @validations[name] << rules unless rules.empty?
21
- @validations[name] << block if block
18
+ def validate(field_name, range: nil, format: nil, &block)
19
+ @validations[field_name] ||= []
20
+ rules = {}
21
+ rules[:range] = range if range
22
+ rules[:format] = format if format
23
+ @validations[field_name] << rules unless rules.empty?
24
+ @validations[field_name] << block if block
22
25
  end
23
26
 
24
27
  def build
25
- fields = @fields
26
- validations = @validations
28
+ fields = @fields.dup
29
+ mutable = @mutable
27
30
 
28
- validations.each do |name, rules|
31
+ # Attach validations to fields
32
+ @validations.each do |name, rules|
29
33
  next unless fields[name]
30
34
 
31
35
  rules.each { |rule| fields[name].add_validation(rule) }
32
36
  end
33
37
 
34
38
  klass = Class.new do
35
- include Validation
36
-
37
- define_method(:_fields_data) { fields }
38
-
39
39
  class << self
40
- attr_accessor :_fields
40
+ attr_accessor :_fields, :_mutable
41
41
  end
42
42
 
43
- fields.each do |name, _field|
44
- attr_reader name
43
+ fields.each_key do |fname|
44
+ attr_reader fname
45
45
  end
46
46
 
47
47
  define_method(:initialize) do |**kwargs|
48
- self.class._fields.each do |name, f|
49
- value = if kwargs.key?(name)
50
- kwargs[name]
48
+ self.class._fields.each do |fname, f|
49
+ value = if kwargs.key?(fname)
50
+ kwargs[fname]
51
51
  elsif f.has_default?
52
52
  f.resolve_default
53
53
  else
54
- raise ArgumentError, "missing keyword: #{name}"
54
+ raise ArgumentError, "missing keyword: #{fname}"
55
55
  end
56
56
 
57
- value = f.coerce_value(value)
58
-
59
- unless f.validate_type(value)
60
- raise TypeError, "#{name} must be a #{f.type}, got #{value.class}"
57
+ unless f.type_valid?(value)
58
+ expected = f.type.is_a?(Array) ? f.type.map(&:name).join(' or ') : f.type.name
59
+ raise TypeError, "#{fname} must be #{expected}, got #{value.class}"
61
60
  end
62
61
 
63
- instance_variable_set(:"@#{name}", value)
62
+ # Run validations
63
+ validation_errors = f.validate_value(value)
64
+ raise ArgumentError, validation_errors.join(', ') unless validation_errors.empty?
65
+
66
+ instance_variable_set(:"@#{fname}", value)
64
67
  end
65
68
 
66
- freeze
69
+ freeze unless self.class._mutable
67
70
  end
68
71
 
69
- define_method(:to_h) do
70
- self.class._fields.each_with_object({}) do |(name, _), hash|
71
- hash[name] = instance_variable_get(:"@#{name}")
72
+ if mutable
73
+ fields.each_key do |fname|
74
+ attr_writer fname
72
75
  end
73
76
  end
74
77
 
75
- define_method(:merge) do |**attrs|
76
- self.class.new(**to_h.merge(attrs))
78
+ define_method(:to_h) do
79
+ self.class._fields.each_with_object({}) do |(fname, _), hash|
80
+ hash[fname] = instance_variable_get(:"@#{fname}")
81
+ end
77
82
  end
78
83
 
79
- define_method(:==) do |other|
80
- other.is_a?(self.class) && to_h == other.to_h
84
+ define_method(:to_json) do |*args|
85
+ require 'json'
86
+ to_h.to_json(*args)
81
87
  end
82
88
 
83
- define_method(:hash) do
84
- to_h.hash
89
+ define_singleton_method(:from_h) do |hash|
90
+ sym_hash = hash.transform_keys(&:to_sym)
91
+ new(**sym_hash)
85
92
  end
86
93
 
87
94
  define_method(:deconstruct_keys) do |keys|
@@ -89,20 +96,24 @@ module Philiprehberger
89
96
  keys ? h.slice(*keys) : h
90
97
  end
91
98
 
92
- define_method(:to_s) do
93
- fields_str = to_h.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
94
- "#<#{self.class.name || 'StructKit'} #{fields_str}>"
99
+ define_method(:==) do |other|
100
+ other.is_a?(self.class) && to_h == other.to_h
95
101
  end
96
102
 
97
- define_method(:inspect) { to_s }
103
+ define_method(:hash) do
104
+ to_h.hash
105
+ end
98
106
 
99
- define_singleton_method(:from_h) do |hash|
100
- sym_hash = hash.transform_keys(&:to_sym)
101
- new(**sym_hash)
107
+ define_method(:inspect) do
108
+ fields_str = to_h.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
109
+ "#<#{self.class.name || 'StructKit'} #{fields_str}>"
102
110
  end
111
+
112
+ alias_method :to_s, :inspect
103
113
  end
104
114
 
105
115
  klass._fields = fields
116
+ klass._mutable = mutable
106
117
  klass
107
118
  end
108
119
  end
@@ -3,37 +3,33 @@
3
3
  module Philiprehberger
4
4
  module StructKit
5
5
  class Field
6
- attr_reader :name, :type, :default, :coerce, :validations
6
+ UNSET = Object.new.freeze
7
7
 
8
- def initialize(name, type = nil, default: :__no_default__, coerce: nil)
8
+ attr_reader :name, :type, :validations
9
+
10
+ def initialize(name, type = nil, default: UNSET)
9
11
  @name = name
10
12
  @type = type
11
13
  @default = default
12
- @coerce = coerce
13
14
  @validations = []
14
15
  end
15
16
 
16
17
  def has_default?
17
- @default != :__no_default__
18
+ @default != UNSET
18
19
  end
19
20
 
20
21
  def resolve_default
21
- return nil unless has_default?
22
-
23
22
  @default.respond_to?(:call) ? @default.call : @default
24
23
  end
25
24
 
26
- def coerce_value(value)
27
- return value unless @coerce
28
-
29
- @coerce.call(value)
30
- end
31
-
32
- def validate_type(value)
25
+ def type_valid?(value)
33
26
  return true if @type.nil?
34
- return true if value.nil? && has_default?
35
27
 
36
- value.is_a?(@type)
28
+ if @type.is_a?(Array)
29
+ @type.any? { |t| value.is_a?(t) }
30
+ else
31
+ value.is_a?(@type)
32
+ end
37
33
  end
38
34
 
39
35
  def add_validation(rule)
@@ -43,19 +39,11 @@ module Philiprehberger
43
39
  def validate_value(value)
44
40
  errors = []
45
41
 
46
- unless validate_type(value)
47
- errors << "#{@name} must be a #{@type}, got #{value.class}"
48
- end
49
-
50
42
  @validations.each do |rule|
51
43
  case rule
52
44
  when Hash
53
- if rule[:range] && !rule[:range].include?(value)
54
- errors << "#{@name} must be in range #{rule[:range]}"
55
- end
56
- if rule[:format] && !rule[:format].match?(value.to_s)
57
- errors << "#{@name} does not match required format"
58
- end
45
+ errors << "#{@name} must be in range #{rule[:range]}" if rule[:range] && !rule[:range].include?(value)
46
+ errors << "#{@name} does not match required format" if rule[:format] && !rule[:format].match?(value.to_s)
59
47
  when Proc
60
48
  msg = rule.call(value)
61
49
  errors << msg if msg.is_a?(String)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module StructKit
5
- VERSION = '0.1.0'
5
+ VERSION = '0.1.2'
6
6
  end
7
7
  end
@@ -2,13 +2,12 @@
2
2
 
3
3
  require_relative 'struct_kit/version'
4
4
  require_relative 'struct_kit/field'
5
- require_relative 'struct_kit/validation'
6
5
  require_relative 'struct_kit/definition'
7
6
 
8
7
  module Philiprehberger
9
8
  module StructKit
10
- def self.define(&block)
11
- defn = Definition.new
9
+ def self.define(mutable: false, &block)
10
+ defn = Definition.new(mutable: mutable)
12
11
  defn.instance_eval(&block)
13
12
  defn.build
14
13
  end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-struct_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
- - Philip Rehberger
7
+ - philiprehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
@@ -14,7 +14,7 @@ description: Define data classes with typed fields, default values, validation r
14
14
  and pattern matching support. Immutable by default with keyword-only construction,
15
15
  JSON/Hash serialization, and runtime type checking.
16
16
  email:
17
- - me@philiprehberger.com
17
+ - philiprehberger@users.noreply.github.com
18
18
  executables: []
19
19
  extensions: []
20
20
  extra_rdoc_files: []
@@ -25,7 +25,6 @@ files:
25
25
  - lib/philiprehberger/struct_kit.rb
26
26
  - lib/philiprehberger/struct_kit/definition.rb
27
27
  - lib/philiprehberger/struct_kit/field.rb
28
- - lib/philiprehberger/struct_kit/validation.rb
29
28
  - lib/philiprehberger/struct_kit/version.rb
30
29
  homepage: https://github.com/philiprehberger/rb-struct-kit
31
30
  licenses:
@@ -44,7 +43,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - ">="
46
45
  - !ruby/object:Gem::Version
47
- version: 3.1.0
46
+ version: '3.1'
48
47
  required_rubygems_version: !ruby/object:Gem::Requirement
49
48
  requirements:
50
49
  - - ">="
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Philiprehberger
4
- module StructKit
5
- module Validation
6
- def valid?
7
- errors.empty?
8
- end
9
-
10
- def errors
11
- errs = []
12
- self.class._fields.each do |name, field|
13
- value = instance_variable_get(:"@#{name}")
14
- errs.concat(field.validate_value(value))
15
- end
16
- errs
17
- end
18
- end
19
- end
20
- end