File Coverage

blib/lib/Net/Amazon/DynamoDB/Marshaler.pm
Criterion Covered Total %
statement 128 130 98.4
branch 88 94 93.6
condition 19 27 70.3
subroutine 17 17 100.0
pod 2 2 100.0
total 254 270 94.0


line stmt bran cond sub pod time code
1             package Net::Amazon::DynamoDB::Marshaler;
2              
3 1     1   98111 use strict;
  1         6  
  1         27  
4 1     1   20 use 5.008_005;
  1         3  
5             our $VERSION = '0.07';
6              
7 1     1   454 use parent qw(Exporter);
  1         289  
  1         4  
8             our @EXPORT = qw(dynamodb_marshal dynamodb_unmarshal);
9              
10 1     1   68 use boolean qw(true false isBoolean);
  1         2  
  1         6  
11 1     1   71 use Scalar::Util qw(blessed);
  1         2  
  1         42  
12 1     1   632 use Types::Standard qw(StrictNum);
  1         72523  
  1         9  
13              
14             sub dynamodb_marshal {
15 25     25 1 102827 my ($attrs, %args) = @_;
16 25   100     121 my $force_type = $args{force_type} || {};
17 25 50 33     132 die __PACKAGE__.'::dynamodb_marshal(): argument must be a hashref'
18             unless (
19             ref $attrs
20             && ref $attrs eq 'HASH'
21             );
22 25 50 33     103 die __PACKAGE__.'::dynamodb_marshal(): force_type must be a hashref'
23             unless (
24             ref $force_type
25             && ref $force_type eq 'HASH'
26             );
27              
28             die __PACKAGE__.qq|::dynamodb_marshal(): invalid force_type value for "$_"|
29 25         86 for grep {
30 19         98 $force_type->{$_} !~ /^[SN]$/;
31             }
32             keys %$force_type;
33              
34 24         70 return _marshal_hashref($attrs, $force_type);
35             }
36              
37             sub dynamodb_unmarshal {
38 10     10 1 78055 my ($attrs) = @_;
39 10 50 33     71 die __PACKAGE__.'::dynamodb_unmarshal(): argument must be a hashref'
40             unless (
41             ref $attrs
42             && ref $attrs eq 'HASH'
43             );
44 10         32 return _unmarshal_hashref($attrs);
45             }
46              
47             sub _marshal_hashref {
48 36     36   75 my ($attrs, $force_types) = @_;
49 36   50     86 $force_types ||= {};
50 36         62 my %marshalled;
51 36         106 for my $key (keys %$attrs) {
52 111         204 my $val = $attrs->{$key};
53 111         188 my $force_type = $force_types->{$key};
54 111         230 my $new_val = _marshal_val($val, $force_type);
55 107 100       329 if ($new_val) {
56 100         284 $marshalled{$key} = $new_val;
57             }
58             }
59 32         280 return \%marshalled;
60             }
61              
62             sub _unmarshal_hashref {
63 15     15   30 my ($attrs) = @_;
64 15         43 return { map { $_ => _unmarshal_attr_val($attrs->{$_}) } keys %$attrs };
  36         96  
65             }
66              
67             sub _marshal_val {
68 144     144   286 my ($val, $force_type) = @_;
69 144   100     471 $force_type ||= '';
70              
71             # Calculate the type according to our rules.
72 144         271 my $type = _val_type($val);
73              
74             # Subref to build string value result
75 142     47   649 my $marshalled_string = sub {{ S => "$_[0]" }};
  47         236  
76              
77             # Subref to build number value result
78 142     35   343 my $marshalled_num = sub {{ N => $_[0] }};
  35         182  
79              
80             # Handle strings
81 142 100       320 if ($type eq 'S') {
82             # Strip these out if we're force-typing to number
83 39 100       82 if ($force_type eq 'N') {
84 3         12 return undef;
85             }
86 36         71 return $marshalled_string->($val);
87             }
88              
89             # Handle numbers
90 103 100       207 if ($type eq 'N') {
91             # Force-stringify if asked
92 42 100       88 if ($force_type eq 'S') {
93 9         20 return $marshalled_string->($val);
94             }
95 33         64 return $marshalled_num->($val);
96             }
97              
98             # Handle nulls
99 61 100       153 if ($type eq 'NULL') {
100             # Strip these out if we're force-typing
101 14 100       36 if ($force_type) {
102 7         25 return undef;
103             }
104 7         33 return { NULL => 1 };
105             }
106              
107             # Handle booleans
108 47 100       104 if ($type eq 'BOOL') {
109             # Force-stringify if asked
110 12 100       26 if ($force_type eq 'S') {
111 2 100       7 return $marshalled_string->($val ? 1 : 0);
112             }
113             # Force-numerify if asked
114 10 100       26 if ($force_type eq 'N') {
115 2 100       6 return $marshalled_num->($val ? 1 : 0);
116             }
117 8 100       21 return { BOOL => $val ? 1 : 0 };
118             }
119              
120             # Handle sets
121 35 100       100 if ($type =~ /^(NS|SS)$/) {
122             # Blow up trying to force-type a set
123 11 100       55 die __PACKAGE__.'force_type not supported for sets yet'
124             if $force_type;
125 9         70 return { $type => [ $val->members ] };
126             }
127              
128             # Handle lists
129 24 100       53 if ($type eq 'L') {
130 12         32 my @items = map { _marshal_val($_, $force_type) } @$val;
  33         73  
131             # Strip out any values that failed force-typing
132 12         30 @items = grep { defined } @items;
  33         76  
133 12         79 return { L => \@items };
134             }
135              
136             # Handle maps
137 12 50       28 if ($type eq 'M') {
138 12         24 my $force_types = {};
139 12 100       29 if ($force_type) {
140             # Recursively apply our force type to all values.
141 7         22 $force_types = { map { $_ => $force_type } keys %$val };
  21         51  
142             }
143 12         40 return { M => _marshal_hashref($val, $force_types) };
144             }
145              
146 0         0 die "don't know how to marshal type of $type";
147             }
148              
149             sub _unmarshal_attr_val {
150 50     50   89 my ($attr_val) = @_;
151 50         93 my ($type, $val) = _get_canonical_attr_val($attr_val);
152              
153 50 100       124 return undef if $type eq 'NULL';
154 48 100       224 return $val if $type =~ /^(S|N)$/;
155 21 100 100     75 return true if $type eq 'BOOL' && $val;
156 18 100       44 return false if $type eq 'BOOL';
157 16 100       82 return Set::Object->new(@$val) if $type =~ /^(NS|SS)$/;
158 12 100       34 return [ map { _unmarshal_attr_val($_) } @$val ] if $type eq 'L';
  14         43  
159 5 50       17 return _unmarshal_hashref($val) if $type eq 'M';
160              
161 0         0 die "don't know how to unmarshal $type";
162             }
163              
164             # Sometimes Paws includes an empty L value in its AttributeValue object, see
165             # https://github.com/pplu/aws-sdk-perl/issues/79
166             sub _get_canonical_attr_val {
167 50     50   78 my ($attr_val) = @_;
168 50         101 my @types = keys %$attr_val;
169 50         78 my $canonical_type;
170 50 100       94 if (@types == 1) {
171 47         76 $canonical_type = $types[0];
172             } else {
173 3         7 ($canonical_type) = grep { $_ ne 'L' } @types;
  6         16  
174             }
175              
176 50         131 return ($canonical_type, $attr_val->{$canonical_type});
177             }
178              
179             sub _val_type {
180 178     178   330 my ($val) = @_;
181              
182 178 100       367 return 'NULL' if ! defined $val;
183 168 100       469 return 'NULL' if $val eq '';
184 164 100       590 return 'N' if _is_valid_number($val);
185 106 100       973 return 'S' if !ref $val;
186              
187 50 100       128 return 'BOOL' if isBoolean($val);
188              
189 38         707 my $ref = ref $val;
190 38 100       102 return 'L' if $ref eq 'ARRAY';
191 26 100       67 return 'M' if $ref eq 'HASH';
192              
193 13 100 66     92 if (blessed($val) and $val->isa('Set::Object')) {
194 12         45 my @types = map { _val_type($_) } $val->members;
  34         69  
195             die "Sets can only contain strings and numbers, found $_"
196 12         33 for grep { !/^(S|N)$/ } @types;
  34         137  
197 11 100       25 if (grep { /^S$/ } @types) {
  32         136  
198 6         23 return 'SS';
199             } else {
200 5         18 return 'NS';
201             }
202             }
203              
204 1         14 die __PACKAGE__.": unable to marshal value: $val";
205             }
206              
207             sub _is_valid_number {
208 164     164   306 my ($val) = @_;
209 164 100       387 return 0 if ref $val;
210 114 100       268 return 0 unless StrictNum->check($val);
211              
212             # Some very high numbers are equal to 0, keep those as strings
213 65 50       1108 return 1 if ("$val" eq '0');
214 65 100       187 return 0 if ($val == 0);
215              
216 64 100 100     247 return 0 if ($val > 0 && $val <= 1e-130);
217 63 100 100     164 return 0 if ($val < 0 && $val >= -1e-130);
218              
219 62 100       137 return 0 if ($val >= 1e126);
220 61 100       127 return 0 if ($val <= -1e126);
221              
222 60 100       127 return 0 if length($val) > 38;
223              
224 58         176 return 1;
225             }
226              
227              
228             1;
229             __END__
230              
231             =encoding utf-8
232              
233             =head1 NAME
234              
235             Net::Amazon::DynamoDB::Marshaler - Translate Perl hashrefs into DynamoDb format and vice versa.
236              
237             =head1 SYNOPSIS
238              
239             use Net::Amazon::DynamoDB::Marshaler;
240              
241             my $item = {
242             name => 'John Doe',
243             age => 28,
244             skills => ['Perl', 'Linux', 'PostgreSQL'],
245             };
246              
247             # Translate a Perl hashref into DynamoDb format
248             my $item_dynamodb = dynamodb_marshal($item);
249              
250             # $item_dynamodb looks like:
251             # {
252             # name => {
253             # S => 'John Doe',
254             # },
255             # age => {
256             # N => 28,
257             # },
258             # skills => {
259             # SS => ['Perl', 'Linux', 'PostgreSQL'],
260             # }
261             # };
262              
263             # Translate a DynamoDb formatted hashref into regular Perl
264             my $item2 = dynamodb_unmarshal($item_dynamodb);
265              
266             =head1 DESCRIPTION
267              
268             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.
269              
270             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.
271              
272             NOTE: this module does not yet support Binary or Binary Set types. Pull requests welcome.
273              
274             =head1 CONVERSION RULES
275              
276             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.
277              
278             For a given Perl value, we use the following rules to pick the DynamoDB type:
279              
280             =over 4
281              
282             =item 1.
283              
284             If the value is undef or an empty string, use Null ('NULL').
285              
286             =item 2.
287              
288             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').
289              
290             =item 3.
291              
292             For any other non-reference, use String ('S').
293              
294             =item 4.
295              
296             If the value is an arrayref, use List ('L').
297              
298             =item 5.
299              
300             If the value is a hashref, use Map ('M').
301              
302             =item 6.
303              
304             If the value isa L<boolean>, use Boolean ('BOOL').
305              
306             =item 7.
307              
308             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.
309              
310             =item 8.
311              
312             Any other value will throw an error.
313              
314             =back
315              
316             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.
317              
318             =head1 EXPORTS
319              
320             By default, dynamodb_marshal and dynamodb_unmarshal are exported.
321              
322             =head2 dynamodb_marshal
323              
324             Takes in a "normal" Perl hashref, transforms it into DynamoDB format.
325              
326             my $attrs_marshalled = dynamodb_marshal($attrs[, force_type => {}]);
327              
328             =head3 force_type
329              
330             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.
331              
332             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.
333              
334             Use force_type in that situation:
335              
336             my $item = {
337             username => '1234',
338             ...
339             };
340              
341             my $force_type = {
342             username => 'S',
343             };
344              
345             my $item_dynamodb = dynamodb_marshal($item, force_type => $force_type);
346              
347             # $item_dynamodb looks like:
348             # {
349             # username => {
350             # S => '1234',
351             # },
352             # ...
353             # };
354              
355             You can only specify 'S' or 'N' for force_type values. If the attribute you specify is a list or map, the forced type will be applied recursively through the data structure. Sets are not currently available for force_type.
356              
357             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.
358              
359             If you specify 'N', and a non-number value is encountered, it will also be removed.
360              
361             =head2 dynamodb_unmarshal
362              
363             The opposite of dynamodb_marshal.
364              
365             my $attrs = dynamodb_unmarshal($attrs_marshalled);
366              
367             =head1 AUTHOR
368              
369             Steve Caldwell E<lt>scaldwell@gmail.comE<gt>
370              
371             =head1 COPYRIGHT
372              
373             Copyright 2017- Steve Caldwell
374              
375             =head1 LICENSE
376              
377             This library is free software; you can redistribute it and/or modify
378             it under the same terms as Perl itself.
379              
380             =head1 SEE ALSO
381              
382             =over 4
383              
384             =item L<Paws::DynamoDB> - the most up-to-date DynamoDB client.
385              
386             =item L<DynamoDB's attribute format|http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_AttributeValue.html>
387              
388             =item L<Amazon::DynamoDB> - DynamoDB client that does conversion for you.
389              
390             =item L<Net::Amazon::DynamoDB> - DynamoDB client that does conversion for you.
391              
392             =item L<WebService::Amazon::DynamoDB> - DynamoDB client that does conversion for you.
393              
394             =item L<Net::Amazon::DynamoDB::Table> - DynamoDB client that does conversion for you.
395              
396             =item L<dynamoDb-marshaler|https://github.com/CascadeEnergy/dynamoDb-marshaler> - JavaScript library that performs a similar function.
397              
398             =back
399              
400             =head1 ACKNOWLEDGEMENTS
401              
402             Thanks to L<Campus Explorer|http://www.campusexplorer.com>, who allowed me to release this code as open source.
403              
404             =cut