Skip to content

Commit 3b31dc9

Browse files
committed
Add support for Data objects with ivars
This sets the ivars _before_ calling initialize, which feels wrong. But Data doesn't give us any mechanism for setting the members other than 1) initialize, or 2) drop down into the C API. Since initialize freezes the object, we need to set the ivars before that. I think this is a reasonable compromise—if users need better handling, they can implement their own `encode_with` and `init_with`. But it will lead to unhappy surprises for some users. Alternatively, we could use the C API, similarly to Marshal. Psych _is_ already using the C API for path2class and build_exception. This would be the least surprising behavior for users, I think.
1 parent 788b844 commit 3b31dc9

File tree

4 files changed

+141
-9
lines changed

4 files changed

+141
-9
lines changed

lib/psych/visitors/to_ruby.rb

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,27 @@ def visit_Psych_Nodes_Mapping o
197197
s
198198
end
199199

200-
when /^!ruby\/data(?::(.*))?$/
201-
data = register(o, resolve_class($1).allocate) if $1
200+
when /^!ruby\/data(-with-ivars)?(?::(.*))?$/
201+
data = register(o, resolve_class($2).allocate) if $2
202202
members = {}
203-
revive_data_members(members, o)
203+
204+
if $1 # data-with-ivars
205+
ivars = {}
206+
o.children.each_slice(2) do |type, vars|
207+
case accept(type)
208+
when 'members'
209+
revive_data_members(members, vars)
210+
data ||= allocate_anon_data(o, members)
211+
when 'ivars'
212+
revive_hash(ivars, vars)
213+
end
214+
end
215+
ivars.each do |ivar, v|
216+
data.instance_variable_set ivar, v
217+
end
218+
else
219+
revive_data_members(members, o)
220+
end
204221
data ||= allocate_anon_data(o, members)
205222
data.send(:initialize, **members)
206223
data

lib/psych/visitors/yaml_tree.rb

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,41 @@ def visit_Object o
163163
alias :visit_Delegator :visit_Object
164164

165165
def visit_Data o
166-
tag = ['!ruby/data', o.class.name].compact.join(':')
167-
register o, @emitter.start_mapping(nil, tag, false, Nodes::Mapping::BLOCK)
168-
o.members.each do |member|
169-
@emitter.scalar member.to_s, nil, nil, true, false, Nodes::Scalar::ANY
170-
accept o.send member
166+
ivars = o.instance_variables
167+
if ivars.empty?
168+
tag = ['!ruby/data', o.class.name].compact.join(':')
169+
register o, @emitter.start_mapping(nil, tag, false, Nodes::Mapping::BLOCK)
170+
o.members.each do |member|
171+
@emitter.scalar member.to_s, nil, nil, true, false, Nodes::Scalar::ANY
172+
accept o.send member
173+
end
174+
@emitter.end_mapping
175+
176+
else
177+
tag = ['!ruby/data-with-ivars', o.class.name].compact.join(':')
178+
node = @emitter.start_mapping(nil, tag, false, Psych::Nodes::Mapping::BLOCK)
179+
register(o, node)
180+
181+
# Dump the members
182+
accept 'members'
183+
@emitter.start_mapping nil, nil, true, Nodes::Mapping::BLOCK
184+
o.members.each do |member|
185+
@emitter.scalar member.to_s, nil, nil, true, false, Nodes::Scalar::ANY
186+
accept o.send member
187+
end
188+
@emitter.end_mapping
189+
190+
# Dump the ivars
191+
accept 'ivars'
192+
@emitter.start_mapping nil, nil, true, Nodes::Mapping::BLOCK
193+
ivars.each do |ivar|
194+
accept ivar.to_s
195+
accept o.instance_variable_get ivar
196+
end
197+
@emitter.end_mapping
198+
199+
@emitter.end_mapping
171200
end
172-
@emitter.end_mapping
173201
end
174202

175203
def visit_Struct o

test/psych/test_data.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
require_relative 'helper'
3+
4+
class PsychDataWithIvar < Data.define(:foo)
5+
attr_reader :bar
6+
def initialize(**)
7+
@bar = 'hello'
8+
super
9+
end
10+
end unless RUBY_VERSION < "3.2"
11+
12+
module Psych
13+
class TestData < TestCase
14+
class SelfReferentialData < Data.define(:foo)
15+
attr_accessor :ref
16+
def initialize(foo:)
17+
@ref = self
18+
super
19+
end
20+
end unless RUBY_VERSION < "3.2"
21+
22+
def setup
23+
omit "Data requires ruby >= 3.2" if RUBY_VERSION < "3.2"
24+
end
25+
26+
# TODO: move to another test?
27+
def test_dump_data
28+
assert_equal <<~eoyml, Psych.dump(PsychDataWithIvar["bar"])
29+
--- !ruby/data-with-ivars:PsychDataWithIvar
30+
members:
31+
foo: bar
32+
ivars:
33+
"@bar": hello
34+
eoyml
35+
end
36+
37+
def test_self_referential_data
38+
circular = SelfReferentialData.new("foo")
39+
40+
loaded = Psych.unsafe_load(Psych.dump(circular))
41+
assert_instance_of(SelfReferentialData, loaded.ref)
42+
43+
assert_equal(circular, loaded)
44+
assert_same(loaded, loaded.ref)
45+
end
46+
47+
def test_roundtrip
48+
thing = PsychDataWithIvar.new("bar")
49+
data = Psych.unsafe_load(Psych.dump(thing))
50+
51+
assert_equal "hello", data.bar
52+
assert_equal "bar", data.foo
53+
end
54+
55+
def test_load
56+
obj = Psych.unsafe_load(<<~eoyml)
57+
--- !ruby/data-with-ivars:PsychDataWithIvar
58+
members:
59+
foo: bar
60+
ivars:
61+
"@bar": hello
62+
eoyml
63+
64+
assert_equal "hello", obj.bar
65+
assert_equal "bar", obj.foo
66+
end
67+
end
68+
end
69+

test/psych/test_serialize_subclasses.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,23 @@ def test_struct_subclass
3535
so = StructSubclass.new('foo', [1,2,3])
3636
assert_equal so, Psych.unsafe_load(Psych.dump(so))
3737
end
38+
39+
class DataSubclass < Data.define(:foo)
40+
def initialize(foo:)
41+
@bar = "hello #{foo}"
42+
super(foo: foo)
43+
end
44+
45+
def == other
46+
super(other) && @bar == other.instance_eval{ @bar }
47+
end
48+
end unless RUBY_VERSION < "3.2"
49+
50+
def test_data_subclass
51+
omit "Data requires ruby >= 3.2" if RUBY_VERSION < "3.2"
52+
so = DataSubclass.new('foo')
53+
assert_equal so, Psych.unsafe_load(Psych.dump(so))
54+
end
55+
3856
end
3957
end

0 commit comments

Comments
 (0)