philosophal 1.2.0 → 2.0.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/README.md +39 -0
  4. data/lib/philosophal/convertor.rb +9 -12
  5. data/lib/philosophal/loaders/json_loader.rb +4 -9
  6. data/lib/philosophal/properties/instance_methods.rb +76 -0
  7. data/lib/philosophal/properties/schema.rb +48 -0
  8. data/lib/philosophal/properties.rb +66 -88
  9. data/lib/philosophal/property.rb +162 -53
  10. data/lib/philosophal/transform.rb +8 -6
  11. data/lib/philosophal/types/array_of_type.rb +4 -3
  12. data/lib/philosophal/types/boolean_type.rb +1 -1
  13. data/lib/philosophal/types/hash_of_type.rb +8 -6
  14. data/lib/philosophal.rb +15 -17
  15. data/sig/manifest.yaml +5 -0
  16. data/sig/philosophal/convertor.rbs +35 -0
  17. data/sig/philosophal/errors/argument_error.rbs +4 -0
  18. data/sig/philosophal/errors/type_error.rbs +4 -0
  19. data/sig/philosophal/loaders/errors.rbs +6 -0
  20. data/sig/philosophal/loaders/json_loader.rbs +29 -0
  21. data/sig/philosophal/loaders.rbs +4 -0
  22. data/sig/philosophal/properties/instance_methods.rbs +26 -0
  23. data/sig/philosophal/properties/schema.rbs +47 -0
  24. data/sig/philosophal/properties.rbs +59 -0
  25. data/sig/philosophal/property.rbs +98 -0
  26. data/sig/philosophal/transform.rbs +7 -0
  27. data/sig/philosophal/types/any_type.rbs +9 -0
  28. data/sig/philosophal/types/array_of_type.rbs +23 -0
  29. data/sig/philosophal/types/boolean_type.rbs +11 -0
  30. data/sig/philosophal/types/hash_of_type.rbs +25 -0
  31. data/sig/philosophal/types.rbs +11 -0
  32. data/sig/philosophal.rbs +12 -0
  33. metadata +43 -14
  34. data/.rubocop.yml +0 -12
  35. data/.ruby-version +0 -1
  36. data/.tool-versions +0 -1
  37. data/Rakefile +0 -12
  38. data/VERSION +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1df7f3c754eeef6776d0e3a18bcfdb81124e413187beb2f580dd550cbdcd046e
4
- data.tar.gz: 03ed60c26e12f8225145e6f00a138da84e3d1aa11ffd1ab1be878df0032e6896
3
+ metadata.gz: d588fe0288b3b4d953de700a9218a607b4e7fc23d3946a8e34b4a1f90a7f1265
4
+ data.tar.gz: cf23c471421f8661992e575bfef9edb21e2a659962c0f54ec1618f0047b23b87
5
5
  SHA512:
6
- metadata.gz: ab3fd3bf30dbaba5d2936979b832b2f9a715b7dbab19eba5d08c7feb4e84aa5affd41247b294ffc7ff9a4c054dcaba932ba2cb98d9c8699b5825d1dbad4c0a9b
7
- data.tar.gz: 2b372e50b9bcf123531cdc0df806061893e86a3ddc70eddc2cc47731a017cff18dea606aea8c3477c2d8f2adf1faaa051c3728ad8645b166a0c5c120e583f951
6
+ metadata.gz: ae95691e4be606dbca97f8c3a1e3ccf96b34a310b95a5f07567cc8e2c80f37491ae92e03ecc4d60cfc851c0147d8b63ea0b9bb4cc16f64b1c32e06b1fdb33ed4
7
+ data.tar.gz: 3db4c56bea72d05e0e7a2d75a65842d567e6f2c33f8a37ffcdf2a82374c404c6cc37ce3dec376e94f004b55d3dd6182de8a15518f962d7f70f7aa26effceeb39
data/CHANGELOG.md CHANGED
@@ -1,4 +1,28 @@
1
- ## [Unreleased]
1
+ ## [2.0.0] - 2026-11-03
2
+
3
+ - Add: new `track_changes:` option for `cprop`, which defines `prop_was` (the value at the last `changes_applied`) and `prop_changed?` (the value differs from it)
4
+ - Add: `changes_applied` records the current values of the tracked properties as the reference
5
+ - Add: `changed_properties` returns a hash of the tracked properties changed since the last `changes_applied`, with their new value
6
+ - Add: RBS type signatures (`sig/`), shipped with the gem
7
+ - Fix: **ArrayOf** and **HashOf** no longer accept `nil` or `false` entries of the wrong type without converting them
8
+ - Fix: converting to **ArrayOf** or **HashOf** no longer modifies the given array or hash
9
+ - Perf: the reader of a property without default is now a plain `attr_reader`
10
+ - Perf: writers, and readers falling back to their default, no longer look up their property in the schema
11
+ - Breaking: redeclaring in a subclass, with a default and `override: false`, a property no longer applies the subclass default or type (the parent methods are kept)
12
+ - Perf: fewer lookups and allocations when converting, loading JSON, `to_json_hash`, `changes_applied` and `changed_properties`
13
+ - Fix: `Properties::Schema` now defines `each`, so its Enumerable methods (`map`, `select`, `find`...) work and yield the properties
14
+
15
+ ## [1.3.0] - 2026-10-19
16
+
17
+ - Breaking: minimum required Ruby version is now **3.3** (was 3.1)
18
+ - Breaking: assigning `nil` no longer converts it (e.g. to `0` or `""`); the property is reset and its reader returns the default value
19
+ - Fix: converting a Numeric to **DateTime** no longer requires ActiveSupport (`Time.zone`)
20
+ - Fix: properties declared in a subclass no longer leak into the parent class
21
+ - Fix: a property set to `false` is no longer replaced by its default value, and predicate methods (`prop?`) take the default into account
22
+ - Fix: **ArrayOf** and **HashOf** instances are now really shared for the same subtypes, and **HashOf** accepts anonymous classes
23
+ - Fix: descriptions are no longer evaluated as Ruby code (parentheses or `#{...}` in a description broke or ran code)
24
+ - Fix: `json.load` no longer modifies the given hash or array, and accepts frozen hashes
25
+ - Fix: `override: true` now also replaces a method defined in the class itself before `cprop`
2
26
 
3
27
  ## [1.0.0] - 2025-03-24
4
28
 
data/README.md CHANGED
@@ -28,3 +28,42 @@ maxime.age = '40'
28
28
  puts maxime.inspect
29
29
  #<Person:0x00007da7088eed88 @first_name="maxime", @last_name="DÉSÉCOT", @age=40>
30
30
  ````
31
+
32
+ ### Tracking changes
33
+
34
+ With `track_changes: true`, a property also gets `<name>_was` and `<name>_changed?`.
35
+ Call `changes_applied` once the object is in a known state (built, loaded, saved...):
36
+ `<name>_was` returns the value at that moment, and `<name>_changed?` tells whether the value differs from it.
37
+
38
+ ```ruby
39
+ class Article
40
+ extend Philosophal::Properties
41
+
42
+ cprop :title, String, track_changes: true
43
+ end
44
+
45
+ article = Article.new
46
+ article.title = 'Draft'
47
+ article.title_changed? # => true (no reference yet)
48
+ article.changes_applied
49
+ article.title_changed? # => false
50
+ article.title = 'Review'
51
+ article.title = 'Final'
52
+ article.title_was # => "Draft"
53
+ article.title_changed? # => true
54
+ article.title = 'Draft'
55
+ article.title_changed? # => false
56
+ ```
57
+
58
+ `changed_properties` returns the tracked properties that changed, with their new value:
59
+
60
+ ```ruby
61
+ article.title = 'Final'
62
+ article.changed_properties # => { title: "Final" }
63
+ article.changes_applied
64
+ article.changed_properties # => {}
65
+ ```
66
+
67
+ Before the first `changes_applied`, the reference is "never assigned": every assigned property is changed.
68
+ `<name>_was` returns the stored value, without falling back to the default.
69
+ `json.load` does not call `changes_applied`: call it on the loaded objects if they must start unchanged.
@@ -87,7 +87,7 @@ module Philosophal
87
87
  return obj.to_datetime if obj.respond_to?(:to_datetime)
88
88
  return DateTime.new(*obj) if obj.is_a?(Array)
89
89
  return DateTime.parse(obj) if obj.is_a?(String)
90
- return Time.zone.at(obj).to_datetime if obj.is_a?(Numeric)
90
+ return Time.at(obj).to_datetime if obj.is_a?(Numeric)
91
91
 
92
92
  raise Philosophal::TypeError
93
93
  end
@@ -102,9 +102,9 @@ module Philosophal
102
102
  return false if obj.zero?
103
103
 
104
104
  elsif obj.is_a?(String)
105
- obj_downcase = obj.downcase
106
- return true if obj_downcase == 'true'
107
- return false if obj_downcase == 'false'
105
+ # casecmp compares ASCII only, which is enough for 'true' and 'false', and allocates no string.
106
+ return true if obj.casecmp('true') == 0 # rubocop:disable Style/NumericPredicate
107
+ return false if obj.casecmp('false') == 0 # rubocop:disable Style/NumericPredicate
108
108
 
109
109
  end
110
110
  raise Philosophal::TypeError
@@ -120,11 +120,8 @@ module Philosophal
120
120
  subtype_convert_method = convert_method_for(subtype)
121
121
  raise Philosophal::TypeError unless subtype_convert_method
122
122
 
123
- convert_to_array(obj).tap do |new_obj|
124
- new_obj.map! do |item|
125
- send(subtype_convert_method, item)
126
- end
127
- end
123
+ # map, not map!: Array#to_a returns the given array itself, which must not be modified.
124
+ convert_to_array(obj).map { |item| send(subtype_convert_method, item) }
128
125
  end
129
126
 
130
127
  def convert_to_hash_of(obj, subtype)
@@ -134,9 +131,9 @@ module Philosophal
134
131
  value_convert_method = convert_method_for(subtype[:value_type])
135
132
  raise Philosophal::TypeError unless value_convert_method
136
133
 
137
- convert_to_hash(obj).tap do |new_obj|
138
- new_obj.transform_keys! { |key| send(key_convert_method, key) }
139
- new_obj.transform_values! { |value| send(value_convert_method, value) }
134
+ # A new hash is built: Hash#to_h returns the given hash itself, which must not be modified.
135
+ convert_to_hash(obj).to_h do |key, value|
136
+ [send(key_convert_method, key), send(value_convert_method, value)]
140
137
  end
141
138
  end
142
139
  end
@@ -27,7 +27,7 @@ module Philosophal
27
27
  def load_from_array(array)
28
28
  raise LoadError, 'invalid json parameter' if array.find { |hash| !hash.is_a?(Hash) }
29
29
 
30
- array.map! do |hash|
30
+ array.map do |hash|
31
31
  load_from_hash(hash)
32
32
  end
33
33
  end
@@ -35,23 +35,18 @@ module Philosophal
35
35
  def load_from_hash(hash)
36
36
  obj = @klass.new
37
37
 
38
- hash.each_key do |key|
38
+ hash.each do |key, value|
39
39
  setter = setter_from_key(key)
40
40
  next unless obj.respond_to?(setter)
41
41
 
42
- obj.public_send(setter, hash.delete(key))
42
+ obj.public_send(setter, value)
43
43
  end
44
44
 
45
45
  obj
46
46
  end
47
47
 
48
48
  def setter_from_key(key)
49
- real_key = @klass.philosophal_properties.json_names_h[key]
50
- property = @klass.philosophal_properties[real_key]
51
-
52
- return :"#{property.name}=" if property
53
-
54
- :"#{key}="
49
+ @klass.philosophal_properties.json_setters[key] || :"#{key}="
55
50
  end
56
51
 
57
52
  def load_from_json(str)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philosophal
4
+ module Properties
5
+ module InstanceMethods
6
+ def cprop?(property_name, klass)
7
+ property = self.class.philosophal_properties.properties_index[property_name.to_sym]
8
+ return false unless property
9
+
10
+ klass = klass.class unless klass.is_a?(Class)
11
+ property.type == klass
12
+ end
13
+
14
+ def philosophal_inspect(light = false)
15
+ keys_map = philosophal_inspect_map
16
+
17
+ if light
18
+ keys_map.reject! do |_, v|
19
+ v.nil? || (v.respond_to?(:empty?) && v.empty?)
20
+ end
21
+ end
22
+
23
+ keys_str = keys_map.map { |k, v| [k, v.inspect].join(': ') }.join(', ')
24
+ "#{self.class}:#{format('0x00%x', object_id << 1)} #{keys_str}"
25
+ end
26
+ alias cprop_inspect philosophal_inspect
27
+
28
+ def philosophal_inspect_map
29
+ self.class.philosophal_properties.to_h do |property|
30
+ [
31
+ property.name,
32
+ send(property.name)
33
+ ]
34
+ end
35
+ end
36
+ alias cprop_inspect_map philosophal_inspect_map
37
+
38
+ def philosophal_values
39
+ self.class.philosophal_properties.properties_index.values
40
+ end
41
+
42
+ # Properties declared with track_changes: true whose value changed since the last changes_applied,
43
+ # with their new value.
44
+ def changed_properties
45
+ self.class.philosophal_properties.tracked.each_with_object({}) do |property, changes|
46
+ next unless send(property.changed_method_name)
47
+
48
+ changes[property.name] = send(property.name)
49
+ end
50
+ end
51
+
52
+ # Records the current values as the reference of the tracked properties: none of them is changed anymore.
53
+ # The values are duplicated, so modifying one in place is still seen as a change.
54
+ def changes_applied
55
+ self.class.philosophal_properties.tracked.each do |property|
56
+ instance_variable_set(property.was_variable_name, instance_variable_get(property.variable_name).dup)
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ def to_json_hash
63
+ self.class.philosophal_properties.json_properties.to_h do |property|
64
+ [
65
+ property.json_name,
66
+ send(property.name)
67
+ ]
68
+ end
69
+ end
70
+
71
+ def to_json(*_args)
72
+ to_json_hash.to_json
73
+ end
74
+ end
75
+ end
76
+ end
@@ -10,6 +10,14 @@ module Philosophal
10
10
  @mutex = Mutex.new
11
11
  end
12
12
 
13
+ # Called by #dup: a subclass gets its own index, so its properties do not leak into the parent.
14
+ def initialize_copy(source)
15
+ super
16
+ @properties_index = source.properties_index.dup
17
+ @mutex = Mutex.new
18
+ clear_caches
19
+ end
20
+
13
21
  attr_reader :properties_index
14
22
 
15
23
  def [](key)
@@ -20,9 +28,18 @@ module Philosophal
20
28
  @properties_index.keys
21
29
  end
22
30
 
31
+ # Yields each property in declaration order; Enumerable builds on it.
32
+ def each(&)
33
+ return enum_for(:each) { @properties_index.size } unless block_given?
34
+
35
+ @properties_index.each_value(&)
36
+ self
37
+ end
38
+
23
39
  def <<(value)
24
40
  @mutex.synchronize do
25
41
  @properties_index[value.name] = value
42
+ clear_caches
26
43
  end
27
44
 
28
45
  self
@@ -47,6 +64,37 @@ module Philosophal
47
64
  [property.json_name, key]
48
65
  end
49
66
  end
67
+
68
+ # Maps each JSON name to the writer of its property.
69
+ def json_setters
70
+ return @json_setters if defined?(@json_setters)
71
+
72
+ @json_setters = @properties_index.each_value.to_h do |property|
73
+ [property.json_name, property.writer_method_name]
74
+ end
75
+ end
76
+
77
+ # Properties declared with json: (exported by to_json_hash).
78
+ def json_properties
79
+ return @json_properties if defined?(@json_properties)
80
+
81
+ @json_properties = @properties_index.each_value.select(&:json)
82
+ end
83
+
84
+ # Properties declared with track_changes: true.
85
+ def tracked
86
+ return @tracked if defined?(@tracked)
87
+
88
+ @tracked = @properties_index.each_value.select(&:track_changes?)
89
+ end
90
+
91
+ private
92
+
93
+ def clear_caches
94
+ %i[@immutables @mutables @json_names_h @json_setters @json_properties @tracked].each do |cache|
95
+ remove_instance_variable(cache) if instance_variable_defined?(cache)
96
+ end
97
+ end
50
98
  end
51
99
  end
52
100
  end
@@ -3,36 +3,22 @@
3
3
  module Philosophal
4
4
  module Properties
5
5
  autoload :Schema, 'philosophal/properties/schema'
6
+ autoload :InstanceMethods, 'philosophal/properties/instance_methods'
6
7
  autoload :JSONLoader, 'philosophal/loaders/json_loader'
7
8
 
8
9
  include Philosophal::Types
9
10
 
10
- def cprop(name, type, default: nil, transform: nil, immutable: false, description: nil, json: false)
11
- default.freeze if default && !(default.is_a?(Proc) || default.frozen?)
11
+ def cprop(name, type, default: nil, transform: nil, immutable: false, description: nil, json: false, override: true,
12
+ track_changes: false)
13
+ __check_philosophal_options__(name, transform:, immutable:, override:, description:, json:, track_changes:)
12
14
 
13
- if transform && !(transform.is_a?(Proc) || transform.is_a?(Symbol))
14
- raise Philosophal::ArgumentError, "transform param must be a Symbol object or a Proc (#{name})."
15
- end
16
-
17
- unless Philosophal::Types::BooleanType::TRUE_FALSE_SET.include?(immutable)
18
- raise Philosophal::ArgumentError, 'immutable param must be true or false.'
19
- end
20
-
21
- if description
22
- raise Philosophal::ArgumentError, 'description param must be a string.' unless description.is_a?(String)
23
-
24
- description.freeze unless description.frozen?
25
- end
26
-
27
- if json
28
- unless json.is_a?(TrueClass) || json.is_a?(String) || json.is_a?(Symbol)
29
- raise Philosophal::ArgumentError, 'json param must be true or a String or a Symbol.'
30
- end
15
+ default.freeze unless default.is_a?(Proc)
16
+ description&.freeze
17
+ json = json.to_sym if json.is_a?(String)
31
18
 
32
- json = json.to_sym if json.is_a?(String)
33
- end
34
-
35
- property = __philosophal_property_class__.new(name:, type:, default:, transform:, immutable:, description:, json:)
19
+ property = __philosophal_property_class__.new(
20
+ name:, type:, default:, transform:, immutable:, description:, json:, override:, track_changes:
21
+ )
36
22
 
37
23
  philosophal_properties << property
38
24
  __define_philosophal_methods__(property)
@@ -76,84 +62,76 @@ module Philosophal
76
62
  end
77
63
 
78
64
  def __define_philosophal_methods__(new_property)
65
+ __set_philosophal_property_constant__(new_property)
79
66
  code = __generate_philosophal_methods__(new_property)
80
67
  __philosophal_extension__.module_eval(code)
81
68
  end
82
69
 
70
+ # The constant is replaced when the property is redeclared in the same class.
71
+ def __set_philosophal_property_constant__(property)
72
+ extension = __philosophal_extension__
73
+ constant_name = property.constant_name
74
+ extension.send(:remove_const, constant_name) if extension.const_defined?(constant_name, false)
75
+ extension.const_set(constant_name, property)
76
+ extension.private_constant(constant_name)
77
+ end
78
+
83
79
  def __philosophal_extension__
84
- if defined?(@__philosophal_extension__)
85
- @__philosophal_extension__
86
- else
87
- @__philosophal_extension__ = Module.new do
88
- def cprop?(property_name, klass)
89
- property = self.class.philosophal_properties.properties_index[property_name.to_sym]
90
- return false unless property
91
-
92
- klass = klass.class unless klass.is_a?(Class)
93
- property.type == klass
94
- end
95
-
96
- def philosophal_inspect(light = false)
97
- keys_map = philosophal_inspect_map
98
-
99
- if light
100
- keys_map.reject! do |_, v|
101
- v.nil? || (v.respond_to?(:empty?) && v.empty?)
102
- end
103
- end
104
-
105
- keys_str = keys_map.map { |k, v| [k, v.inspect].join(': ') }.join(', ')
106
- "#{self.class}:#{format('0x00%x', object_id << 1)} #{keys_str}"
107
- end
108
- alias cprop_inspect philosophal_inspect
109
-
110
- def philosophal_inspect_map
111
- philosophal_values.to_h do |property|
112
- [
113
- property.name,
114
- send(property.name)
115
- ]
116
- end
117
- end
118
- alias cprop_inspect_map philosophal_inspect_map
119
-
120
- def philosophal_values
121
- self.class.philosophal_properties.properties_index.values
122
- end
123
-
124
- def to_json_hash
125
- philosophal_values.select(&:json).to_h do |property|
126
- [
127
- property.json_name,
128
- send(property.name)
129
- ]
130
- end
131
- end
132
-
133
- def to_json(*_args)
134
- to_json_hash.to_json
135
- end
136
- end
80
+ @__philosophal_extension__ ||= Module.new { include Philosophal::Properties::InstanceMethods }
81
+ end
82
+
83
+ def __generate_philosophal_methods__(new_property)
84
+ buffer = +''
85
+
86
+ new_property.method_generators.each do |method_name, generator|
87
+ next unless new_property.override? || !method_defined?(method_name)
88
+
89
+ __remove_philosophal_own_method__(method_name) if new_property.override?
90
+ new_property.public_send(generator, buffer)
137
91
  end
92
+
93
+ # puts buffer
94
+ return buffer if buffer.empty?
95
+
96
+ +"# frozen_string_philosophal: true\n" << buffer
138
97
  end
139
98
 
140
- def __generate_philosophal_methods__(new_property, buffer = +'')
141
- buffer << "# frozen_string_philosophal: true\n"
142
- if new_property.immutable
143
- new_property.generate_immutable_writer_method(buffer)
144
- else
145
- new_property.generate_writer_method(buffer)
99
+ # Generated methods live in an included module, so a method defined in the class itself
100
+ # would hide them: it is removed to let the property override it. The method generated by a
101
+ # previous declaration of the property is removed too, so redefining it raises no warning.
102
+ def __remove_philosophal_own_method__(method_name)
103
+ [self, __philosophal_extension__].each do |owner|
104
+ next unless owner.method_defined?(method_name, false) || owner.private_method_defined?(method_name, false)
105
+
106
+ owner.remove_method(method_name)
146
107
  end
108
+ end
147
109
 
148
- new_property.generate_reader_method(buffer)
110
+ def __check_philosophal_options__(name, transform:, immutable:, override:, description:, json:, track_changes:)
111
+ __check_philosophal_transform_option__(name, transform)
112
+ __check_philosophal_boolean_option__(:override, override)
113
+ __check_philosophal_boolean_option__(:immutable, immutable)
114
+ __check_philosophal_boolean_option__(:track_changes, track_changes)
149
115
 
150
- new_property.generate_boolean_method(buffer) if new_property.type == Philosophal::Types::BooleanType::Instance
116
+ if description && !description.is_a?(String)
117
+ raise Philosophal::ArgumentError, 'description param must be a string.'
118
+ end
151
119
 
152
- new_property.generate_description_method(buffer) if new_property.description
120
+ return if !json || json == true || json.is_a?(String) || json.is_a?(Symbol)
153
121
 
154
- # puts buffer
122
+ raise Philosophal::ArgumentError, 'json param must be true or a String or a Symbol.'
123
+ end
124
+
125
+ def __check_philosophal_transform_option__(name, transform)
126
+ return if !transform || transform.is_a?(Proc) || transform.is_a?(Symbol)
127
+
128
+ raise Philosophal::ArgumentError, "transform param must be a Symbol object or a Proc (#{name})."
129
+ end
130
+
131
+ def __check_philosophal_boolean_option__(option_name, value)
132
+ return if Philosophal::Types::BooleanType::TRUE_FALSE_SET.include?(value)
155
133
 
156
- buffer
134
+ raise Philosophal::ArgumentError, "#{option_name} param must be true or false."
157
135
  end
158
136
  end
159
137
  end