Beefy Boxes and Bandwidth Generously Provided by pair Networks
Pathologically Eclectic Rubbish Lister
 
PerlMonks  

How to encode/decode a class inside a class in JSON

by Nordikelt (Novice)
on Aug 20, 2020 at 01:32 UTC ( [id://11120920]=perlquestion: print w/replies, xml ) Need Help??

Nordikelt has asked for the wisdom of the Perl Monks concerning the following question:

Here is a simple example of what I am attempting. I start with with MyClassA which will contain an instance of MyClassB:

package MyClassA; use strict; use warnings 'all'; sub new { my $classname = shift; my $self = {}; bless($self, $classname); my %extras = @_; @$self{keys %extras} = values %extras; return $self; } sub TO_JSON { my $self = shift; return { %{$self} }; } sub setClassB { my $self = shift; $self->{MY_CLASS_B} = shift; } sub getClassB { my $self = shift; return $self->{MY_CLASS_B}; } 1;

and ,

package MyClassB; use strict; use warnings 'all'; sub new { my $classname = shift; my $self = {}; bless($self, $classname); my %extras = @_; @$self{keys %extras} = values %extras; return $self; } sub TO_JSON { my $self = shift; return { %{$self} }; } sub getName { my $self = shift; return $self->{NAME}; } 1;

Next, we build and encode MyClassA:

use strict; use warnings 'all'; use JSON::MaybeXS ':all'; use MyClassA; use MyClassB; my $a = MyClassA->new(NAME => 'A instance'); my $b = MyClassB->new(NAME => 'B instance'); $a->setClassB($b); print JSON->new()->convert_blessed()->encode($a) . "\n";

And we get output that looks good:

{"NAME":"A instance","MY_CLASS_B":{"NAME":"B instance"}}

The trouble is seen when we try to read and use this JSON:

use strict; use warnings 'all'; use JSON::MaybeXS ':all'; use MyClassA; use MyClassB; my $line = <STDIN>; my $a = bless(JSON->new()->decode($line), 'MyClassA'); print $a->getClassB()->getName() . "\n";

Which yields the following error:

Can't call method "getName" on unblessed reference at test_read.pl line 12, <STDIN> line 1.

I suppose I could bless(JSON->new()->decode($a->getClassB()), 'MyClassB'), but is that really the proper way to deal with this situation? Isn't there some way to bless a decoded MyClassA and get something that is blessed deeply? Thanks for your help!

Replies are listed 'Best First'.
Re: How to encode/decode a class inside a class in JSON
by tobyink (Canon) on Aug 20, 2020 at 10:31 UTC

    This is called coercion or unmarshalling. Using Moo + Types::Standard makes it pretty easy...

    use v5.12; use strict; use warnings; package ToJson { use Moo::Role; sub TO_JSON { my $self = shift; return { %$self }; } } package Named { use Moo::Role; use Types::Standard -types; has NAME => ( is => 'bare', isa => Str, reader => 'getName', ); } package MyClassA { use Moo; use Types::Standard -types; with 'ToJson', 'Named'; has MY_CLASS_B => ( is => 'bare', reader => 'getClassB', writer => 'setClassB', isa => InstanceOf->of('MyClassB')->plus_constructors( Has +hRef, 'new' ), coerce => 1, ); } package MyClassB { use Moo; with 'ToJson', 'Named'; } use JSON::MaybeXS; my $a = MyClassA->new(NAME => 'A instance'); my $b = MyClassB->new(NAME => 'B instance'); $a->setClassB($b); my $json = JSON->new->convert_blessed->encode($a); say $json; my $inflated = MyClassA->new( JSON->new->decode($json) ); say $inflated->getClassB->getName;

    The key part is this:

    isa => InstanceOf->of('MyClassB')->plus_constructors( Has +hRef, 'new' ), coerce => 1,

    This means that when the MY_CLASS_B attribute gets passed a hashref instead of an instance of MyClassB, it should call MyClassB->new on that hashref. This coercion happens in both the constructor (new) and the attribute writer (setClassB).

    Of course, it can be done without Moo or Types::Standard. They're not magic. To do it manually, alter your MyClassA::new to check %extras to find if there's any values that need inflating. Something like this:

    if ( exists $extras{MY_CLASS_B} and ref $extras{MY_CLASS_B} eq 'HA +SH' ) { $extras{MY_CLASS_B} = MyClassB->new( %{ $extras{MY_CLASS_B} } +); }

    And probably a good idea to do something similar in setClassB. Allow it to accept a hashref and inflate it to a MyClassB object.

      Obligatory Zydeco example...

      use v5.12; use strict; use warnings; use JSON::MaybeXS; package MyApp { use Zydeco; role ToJson { method TO_JSON { return { %$self }; } } role Named { has NAME ( is => bare, type => Str, reader => 'getName', ); } class MyClassA with ToJson, Named { has MY_CLASS_B ( is => bare, reader => 'getClassB', writer => 'setClassB', type => 'MyClassB', ); } class MyClassB with ToJson, Named { coerce from HashRef via new; } } my $a = MyApp->new_myclassa( NAME => 'A instance' ); my $b = MyApp->new_myclassb( NAME => 'B instance' ); $a->setClassB($b); my $json = JSON->new->convert_blessed->encode($a); say $json; my $inflated = MyApp->new_myclassa( JSON->new->decode($json) ); say $inflated->getClassB->getName;

      Wow, that was quite the lesson. A lot of stuff packed in there that was new to me, but I believe I understand it now.

      Thanks much for your help!

Re: How to encode/decode a class inside a class in JSON
by haj (Vicar) on Aug 20, 2020 at 11:03 UTC

    Well, the root cause is that "plain" JSON actually doesn't know Perl objects: What JSON calls "objects", are actually hash references in Perl. Your TO_JSON method converts the inner object into a hash, but look closely at your output:

    And we get output that looks good: {"NAME":"A instance","MY_CLASS_B":{"NAME":"B instance"}}

    Actually, the output only appears to look good because you used "MY_CLASS_B" as the key in your object $a and as the class name for the object it contains. But look at your TO_JSON method: Actually, the class name MY_CLASS_B is missing from the serialized string, and therefore the deserializer couldn't bless the hash reference even if it wanted to.

    That's why the either "tagging" or post-processing the JSON output, as mentioned in the section Object Serialization of the JSON docs, is required.

Re: How to encode/decode a class inside a class in JSON
by Fletch (Bishop) on Aug 20, 2020 at 02:54 UTC

    Just a WAG, but have you tried calling allow_blessed(1) on your JSON instance?

    Edit: Never mind me it doesn't affect decode. I think you need to look at the section "OBJECT SERIALIZATION" and it's discussion of allow_tags and needing a FREEZE method instead of TO_JSON.

    The cake is a lie.
    The cake is a lie.
    The cake is a lie.

      Just tried it, and it has no effect. I see the same behavior.

Re: How to encode/decode a class inside a class in JSON
by perlfan (Vicar) on Aug 20, 2020 at 03:35 UTC
    This:
    sub TO_JSON { my $self = shift; return { %{$self} }; }

    needs to return a string.

    If you need to encode a data structure that may contain objects, you usually need to look into the structure and replace objects with alternative non-blessed values, or enable convert_blessed and provide a TO_JSON method for each object's (base) class that may be found in the structure, in order to let the methods replace the objects with whatever scalar values the methods return.
    

    See #2 in "Object Serialization" section of JSON.

    Nevermind, I misread your question. Under "Deserialization", there is the section about implementing a THAW method. I have never tried this, but the docs imply the FREEZE method needs provide the needed information needed for your THAW implementation.

    In the past when I've done something like this, I passed the module name as an additional field in the serialized JSON. Then I eval'd the constuctor using the relevant decoded JSON string as the input to said constructor. I am not recommending that, but it was long ago before I thought to look at how to handle this properly in the JSON family of modules. I didn't even use TO_JSON, which I should have. I certainly didn't use FREEZE or THAW. Today I'd try to do it that way.

Re: How to encode/decode a class inside a class in JSON
by perlfan (Vicar) on Aug 20, 2020 at 18:32 UTC
    Below comments are on target, I believe. Basically, you:

    1. enable allow_tags.
    2. implement FREEZE and THAW in your class(es) that are being encoded in the JSON
    3. set type in FREEZE with __PACKAGE__ (or something you know)
    4. in __PACKAGE__ implement THAW method that allows you to "inflate" the decoded reference resulting from the JSON

    There are a few blind spots in my mind, but they're mostly due to me not trying this out to see what the resulting JSON looks like when FREEZE is invoked and to see how THAW might handle it. Note, these methods are called implicitly when you allow_tags.

      When you enable allow_tags, the resulting serialization is no longer valid JSON though.

        Thanks, that is where I was confused. In that case, you may just be left with adding an internal field _type that tracks the intended __PACKAGE__. Seems like anything that is done will require some custom coordination between what gets dumped via TO_JSON and whatever is used to inflate the instance after an initial decode. As I mentioned earlier, I've solved this in the past with a _type field that tracked the __PACKAGE__ name, but the instances were not nested - which in this case complicates it during the decode step.

        Here's what I suggest:

        • leverage TO_JSON during encode for all member references; for all include a known internal field that signifies what __PACKAGE__ it is meant to inflate (e.g., _type or _package
        • decode JSON (this will give you a reference with no blessed members initially)
        • then use a custom inflator to walk the reference and inflate known members using their section of JSON (you'll either have to assume the package based on key/arraypos or it will carried as an internal field in the JSON)

        The spirit of this can be described as having the JSON moduling knowing when to call a FROM_JSON from the specified package when it sees a special internal field that contains which package to inflate. Since symmetry of this capability is not provided by the JSON module itself, you'll have to implement it yourself as part of the decode step.

Re: How to encode/decode a class inside a class in JSON
by Anonymous Monk on Aug 20, 2020 at 18:33 UTC
    See above: "haj" is right. In order to cause something to "behave as a Perl object," you must bless() it, and the JSON deserializer by-itself does not know how to do that (and cannot be expected to).

Log In?
Username:
Password:

What's my password?
Create A New User
Domain Nodelet?
Node Status?
node history
Node Type: perlquestion [id://11120920]
Front-paged by Corion
help
Chatterbox?
and the web crawler heard nothing...

How do I use this?Last hourOther CB clients
Other Users?
Others meditating upon the Monastery: (1)
As of 2024-04-24 16:43 GMT
Sections?
Information?
Find Nodes?
Leftovers?
    Voting Booth?

    No recent polls found