File Coverage

blib/lib/Net/Amazon/DynamoDB/Marshaler.pm
Criterion Covered Total %
statement 88 90 97.7
branch 59 64 92.1
condition 18 25 72.0
subroutine 15 15 100.0
pod 2 2 100.0
total 182 196 92.8


line stmt bran cond sub pod time code
1             package Net::Amazon::DynamoDB::Marshaler;
2              
3 1     1   41903 use strict;
  1         2  
  1         23  
4 1     1   15 use 5.008_005;
  1         4  
5             our $VERSION = '0.05';
6              
7 1     1   402 use parent qw(Exporter);
  1         205  
  1         5  
8             our @EXPORT = qw(dynamodb_marshal dynamodb_unmarshal);
9              
10 1     1   57 use boolean qw(true false isBoolean);
  1         2  
  1         7  
11 1     1   60 use Scalar::Util qw(blessed);
  1         2  
  1         42  
12 1     1   482 use Types::Standard qw(StrictNum);
  1         54369  
  1         8  
13              
14             sub dynamodb_marshal {
15 18     18 1 52230 my ($attrs, %args) = @_;
16 18   100     95 my $force_type = $args{force_type} || {};
17 18 50 33     99 die __PACKAGE__.'::dynamodb_marshal(): argument must be a hashref'
18             unless (
19             ref $attrs
20             && ref $attrs eq 'HASH'
21             );
22 18 50 33     83 die __PACKAGE__.'::dynamodb_marshal(): force_type must be a hashref'
23             unless (
24             ref $force_type
25             && ref $force_type eq 'HASH'
26             );
27 18         47 return _marshal_hashref($attrs, $force_type);
28             }
29              
30             sub dynamodb_unmarshal {
31 9     9 1 48721 my ($attrs) = @_;
32 9 50 33     63 die __PACKAGE__.'::dynamodb_unmarshal(): argument must be a hashref'
33             unless (
34             ref $attrs
35             && ref $attrs eq 'HASH'
36             );
37 9         27 return _unmarshal_hashref($attrs);
38             }
39              
40             sub _marshal_hashref {
41 23     23   46 my ($attrs, $force_type) = @_;
42 23   100     66 $force_type ||= {};
43 23         39 my %marshalled;
44 23         70 for my $key (keys %$attrs) {
45 68         121 my $val = $attrs->{$key};
46 68         98 my $new_val;
47 68 100       135 if (my $type = $force_type->{$key}) {
48 9         35 $new_val = _marshal_val_force_type($val, $type);
49             } else {
50 59         129 $new_val = _marshal_val($val);
51             }
52 65 100       218 if ($new_val) {
53 60         126 $marshalled{$key} = $new_val;
54             }
55             }
56 20         118 return \%marshalled;
57             }
58              
59             sub _unmarshal_hashref {
60 14     14   30 my ($attrs) = @_;
61 14         38 return { map { $_ => _unmarshal_attr_val($attrs->{$_}) } keys %$attrs };
  33         87  
62             }
63              
64             sub _marshal_val {
65 75     75   131 my ($val) = @_;
66 75         137 my $type = _val_type($val);
67              
68 73 100       844 return { $type => $val } if $type =~ /^(N|S)$/;
69 28 100       73 return { $type => 1 } if $type eq 'NULL';
70 22 100       53 return { $type => $val ? 1 : 0 } if $type eq 'BOOL';
    100          
71 18 100       62 return { $type => [ $val->members ] } if $type =~ /^(NS|SS)$/;
72 14 100       38 return { $type => [ map { _marshal_val($_) } @$val ] } if ($type eq 'L');
  16         37  
73 5 50       20 return { $type => _marshal_hashref($val) } if ($type eq 'M');
74              
75 0         0 die "don't know how to marshal type of $type";
76             }
77              
78             sub _marshal_val_force_type {
79 9     9   18 my ($val, $type) = @_;
80              
81 9 100       23 if ($type eq 'N') {
82 4 100       10 return undef unless StrictNum->check($val);
83 1         18 return { N => $val };
84             }
85              
86 5 100       13 if ($type eq 'S') {
87 4 100 100     18 return undef unless (defined $val && length($val));
88 2         8 return { S => "$val" };
89             }
90              
91 1         13 die __PACKAGE__.'::dynamodb_marshal(): force_type only supports "S" and "N" types';
92             }
93              
94             sub _unmarshal_attr_val {
95 47     47   87 my ($attr_val) = @_;
96 47         102 my ($type, $val) = %$attr_val;
97              
98 47 100       114 return undef if $type eq 'NULL';
99 45 100       186 return $val if $type =~ /^(S|N)$/;
100 20 100 100     64 return true if $type eq 'BOOL' && $val;
101 18 100       43 return false if $type eq 'BOOL';
102 16 100       78 return Set::Object->new(@$val) if $type =~ /^(NS|SS)$/;
103 12 100       32 return [ map { _unmarshal_attr_val($_) } @$val ] if $type eq 'L';
  14         31  
104 5 50       23 return _unmarshal_hashref($val) if $type eq 'M';
105              
106 0         0 die "don't know how to unmarshal $type";
107             }
108              
109             sub _val_type {
110 88     88   154 my ($val) = @_;
111              
112 88 100       211 return 'NULL' if ! defined $val;
113 84 100       264 return 'NULL' if $val eq '';
114 82 100       262 return 'N' if _is_number($val);
115 54 100       644 return 'S' if !ref $val;
116              
117 25 100       68 return 'BOOL' if isBoolean($val);
118              
119 21         397 my $ref = ref $val;
120 21 100       53 return 'L' if $ref eq 'ARRAY';
121 12 100       31 return 'M' if $ref eq 'HASH';
122              
123 6 100 66     50 if (blessed($val) and $val->isa('Set::Object')) {
124 5         18 my @types = map { _val_type($_) } $val->members;
  13         139  
125             die "Sets can only contain strings and numbers, found $_"
126 5         51 for grep { !/^(S|N)$/ } @types;
  13         53  
127 4 100       8 if (grep { /^S$/ } @types) {
  11         28  
128 2         6 return 'SS';
129             } else {
130 2         6 return 'NS';
131             }
132             }
133              
134 1         12 die __PACKAGE__.": unable to marshal value: $val";
135             }
136              
137             sub _is_number {
138 82     82   136 my ($val) = @_;
139             return (
140 82   100     278 (!ref $val)
141             && StrictNum->check($val)
142             && (
143             $val == 0
144             || (
145             $val < '1E+126'
146             && $val > '1E-130'
147             )
148             || (
149             $val < '-1E-130'
150             && $val > '-1E+126'
151             )
152             )
153             && length($val) <= 38
154             );
155             }
156              
157              
158             1;
159             __END__
160              
161             =encoding utf-8
162              
163             =head1 NAME
164              
165             Net::Amazon::DynamoDB::Marshaler - Translate Perl hashrefs into DynamoDb format and vice versa.
166              
167             =head1 SYNOPSIS
168              
169             use Net::Amazon::DynamoDB::Marshaler;
170              
171             my $item = {
172             name => 'John Doe',
173             age => 28,
174             skills => ['Perl', 'Linux', 'PostgreSQL'],
175             };
176              
177             # Translate a Perl hashref into DynamoDb format
178             my $item_dynamodb = dynamodb_marshal($item);
179              
180             # $item_dynamodb looks like:
181             # {
182             # name => {
183             # S => 'John Doe',
184             # },
185             # age => {
186             # N => 28,
187             # },
188             # skills => {
189             # SS => ['Perl', 'Linux', 'PostgreSQL'],
190             # }
191             # };
192              
193             # Translate a DynamoDb formatted hashref into regular Perl
194             my $item2 = dynamodb_unmarshal($item_dynamodb);
195              
196             =head1 DESCRIPTION
197              
198             AWS' L<DynamoDB|http://aws.amazon.com/dynamodb/> service expects attributes in a somewhat cumbersome format in which you must specify the attribute type as well as its name and value(s). This module simplifies working with DynamoDB by abstracting away the notion of types and letting you use more intuitive data structures.
199              
200             There are a handful of CPAN modules which provide a DynamoDB client that do similar conversions. However, in all of these cases the conversion is tightly bound to the client implementation. This module exists in order to decouple the functionality of formatting with the functionality of making AWS calls.
201              
202             NOTE: this module does not yet support Binary or Binary Set types. Pull requests welcome.
203              
204             =head1 CONVERSION RULES
205              
206             See <the AWS documentation|http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.DataTypes> for more details on the various types supported by DynamoDB.
207              
208             For a given Perl value, we use the following rules to pick the DynamoDB type:
209              
210             =over 4
211              
212             =item 1.
213              
214             If the value is undef or an empty string, use Null ('NULL').
215              
216             =item 2.
217              
218             If the value is a number (per StrictNum in L<Types::Standard>), and falls within the accepted range for a DynamoDB number, use Number ('N').
219              
220             =item 3.
221              
222             For any other non-reference, use String ('S').
223              
224             =item 4.
225              
226             If the value is an arrayref, use List ('L').
227              
228             =item 5.
229              
230             If the value is a hashref, use Map ('M').
231              
232             =item 6.
233              
234             If the value isa L<boolean>, use Boolean ('BOOL').
235              
236             =item 7.
237              
238             If the value isa L<Set::Object>, use either Number Set ('NS') or String Set ('SS'), depending on whether all members are numbers or not. All members must be defined, non-reference values, or an error will be thrown.
239              
240             =item 8.
241              
242             Any other value will throw an error.
243              
244             =back
245              
246             When doing the opposite - un-marshalling a hashref fetched from DynamoDB - the module applies the rules above in reverse. Please note that NULLs get unmarshalled as undefs, so an empty string will be re-written to undef if it goes through a marshal/unmarshal cycle. DynamoDB does not allow for a way to store empty strings as distinct from NULL.
247              
248             =head1 EXPORTS
249              
250             By default, dynamodb_marshal and dynamodb_unmarshal are exported.
251              
252             =head2 dynamodb_marshal
253              
254             Takes in a "normal" Perl hashref, transforms it into DynamoDB format.
255              
256             my $attrs_marshalled = dynamodb_marshal($attrs[, force_type => {}]);
257              
258             =head3 force_type
259              
260             Sometimes you want to explicitly choose a type for an attribute, overridding the rules above. Most commonly this issue occurs for key attributes, as DynamoDB enforces consistent typing on these attributes that it doesn't enforce otherwise.
261              
262             For instance, you might have a table named 'users' whose partition key is a string named 'username'. If you have incoming data with a username of '1234', this module will tell DynamoDB to store that as a number, which will result in an error.
263              
264             Use force_type in that situation:
265              
266             my $item = {
267             username => '1234',
268             ...
269             };
270              
271             my $force_type = {
272             username => 'S',
273             };
274              
275             my $item_dynamodb = dynamodb_marshal($item, force_type => $force_type);
276              
277             # $item_dynamodb looks like:
278             # {
279             # username => {
280             # S => '1234',
281             # },
282             # ...
283             # };
284              
285             The module only supports 'S' and 'N' types for force_type. If you specify 'S', dynamodb_marshal will stringify the value, so make sure not to send arrays, etc. as values. If you specify 'N', dynamodb_marshal will set the value to undef if it's not a number.
286              
287             Undefs or empty string values for force_type attributes will be removed from the marshalled hashref. While this behavior might not seem intuitive at first, it's almost certainly what you want. For instance, if you have a global secondary index on a string attribute, and your item has an undef value for that attribute, you want to avoid sending that attribute (using NULL would be rejected by DynamoDB, and you can't send empty strings). If you have an undef value for a primary key string attribute, you have a bug in your application somewhere.
288              
289             =head2 dynamodb_unmarshal
290              
291             The opposite of dynamodb_marshal.
292              
293             my $attrs = dynamodb_unmarshal($attrs_marshalled);
294              
295             =head1 AUTHOR
296              
297             Steve Caldwell E<lt>scaldwell@gmail.comE<gt>
298              
299             =head1 COPYRIGHT
300              
301             Copyright 2017- Steve Caldwell
302              
303             =head1 LICENSE
304              
305             This library is free software; you can redistribute it and/or modify
306             it under the same terms as Perl itself.
307              
308             =head1 SEE ALSO
309              
310             =over 4
311              
312             =item L<Paws::DynamoDB> - the most up-to-date DynamoDB client.
313              
314             =item L<DynamoDB's attribute format|http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_AttributeValue.html>
315              
316             =item L<Amazon::DynamoDB> - DynamoDB client that does conversion for you.
317              
318             =item L<Net::Amazon::DynamoDB> - DynamoDB client that does conversion for you.
319              
320             =item L<WebService::Amazon::DynamoDB> - DynamoDB client that does conversion for you.
321              
322             =item L<Net::Amazon::DynamoDB::Table> - DynamoDB client that does conversion for you.
323              
324             =item L<dynamoDb-marshaler|https://github.com/CascadeEnergy/dynamoDb-marshaler> - JavaScript library that performs a similar function.
325              
326             =back
327              
328             =head1 ACKNOWLEDGEMENTS
329              
330             Thanks to L<Campus Explorer|http://www.campusexplorer.com>, who allowed me to release this code as open source.
331              
332             =cut