File Coverage

blib/lib/Net/Amazon/DynamoDB/Marshaler.pm
Criterion Covered Total %
statement 120 122 98.3
branch 86 92 93.4
condition 19 27 70.3
subroutine 16 16 100.0
pod 2 2 100.0
total 243 259 93.8


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