File Coverage

blib/lib/Bb/Collaborate/Ultra/DAO.pm
Criterion Covered Total %
statement 103 217 47.4
branch 19 72 26.3
condition 6 59 10.1
subroutine 20 32 62.5
pod 12 13 92.3
total 160 393 40.7


line stmt bran cond sub pod time code
1             package Bb::Collaborate::Ultra::DAO;
2 6     6   2506 use warnings; use strict;
  6     6   22  
  6         150  
  6         18  
  6         7  
  6         114  
3 6     6   17 use Mouse;
  6         8  
  6         22  
4 6     6   3532 use parent qw{Class::Data::Inheritable};
  6         1391  
  6         27  
5 6     6   5282 use JSON;
  6         28804  
  6         32  
6 6     6   2552 use Bb::Collaborate::Ultra::Util;
  6         12  
  6         157  
7 6     6   27 use Mouse::Util::TypeConstraints;
  6         6  
  6         50  
8 6     6   2883 use Data::Compare;
  6         45755  
  6         31  
9 6     6   17978 use Clone;
  6         11428  
  6         223  
10            
11 6     6   83 use 5.008003;
  6         12  
12              
13             __PACKAGE__->mk_classdata('_types');
14             __PACKAGE__->mk_classdata('_db_data');
15             __PACKAGE__->mk_classdata('resource');
16             __PACKAGE__->mk_classdata('_query_params' => {
17             limit => 'Int',
18             offset => 'Int',
19             fields => 'Str',
20             });
21             has '_connection' => ('is' => 'rw');
22             has '_parent' => ('is' => 'rw');
23              
24             our %enums;
25              
26             =pod
27              
28             L is an abstract base class for various resource classes (e.g. L) and contains both builder and inherited methods from implementing these classes.
29              
30             =head1 ABSTRACT METHODS
31              
32             The following methods are inherited from this class.
33              
34             =cut
35            
36             =head2 new
37              
38             Creates a new object.
39              
40             =cut
41            
42             =head2 post
43              
44             Creates object on the server. E.g.
45              
46             my $start = time() + 60;
47             my $end = $start + 900;
48             my $session = Bb::Collaborate::Ultra::Session->post($connection, {
49             name => 'Test Session',
50             startTime => $start,
51             endTime => $end,
52             },
53             );
54              
55             =cut
56            
57             sub post {
58 0     0 1 0 my $class = shift;
59 0         0 my $connection = shift;
60 0         0 my $data = shift;
61 0 0 0     0 die 'usage: '.$class.'->post($connection, $data)'
      0        
62             unless $connection && $data && $connection->can('POST');
63 0         0 my %opt = @_;
64 0         0 my $json = $class->_freeze($data);
65 0 0 0     0 my $path = $opt{path} || $class->path
66             or die "no POST path";
67              
68 0         0 my $msg = $connection->POST($path, $json, @_);
69 0         0 $class->construct($msg, connection => $connection);
70             }
71              
72             =head2 patch
73              
74             Updates an existing object
75              
76             $session->name('Test Session - Updated');
77             $session->endTime($session->endTime + 60);
78             $session->patch; # enact updates
79              
80             =cut
81              
82             sub patch {
83 0     0 1 0 my $self = shift;
84 0   0     0 my $connection = shift || $self->connection
85             || die "no connected";
86 0   0     0 my $update_data = shift || $self->_pending_updates;
87 0   0     0 my $class = ref($self) || $self;
88 0         0 my $path = $self->path;
89 0         0 my $json = $class->_freeze($update_data);
90 0         0 my $msg = $connection->PATCH($path, $json);
91 0         0 my $obj = $self->construct($msg, connection => $connection);
92 0 0       0 if ($self) {
93 0         0 $self->_db_data( $obj->_db_data );
94 0         0 $obj->parent($self->parent);
95             }
96 0         0 $obj;
97             }
98              
99             =head2 get
100              
101             Fetches one or more objects from the server.
102              
103             my @future_sessions = Bb::Collaborate::Ultra::Session->get($connection, {endTime => time(), limit => 50}, )
104              
105             =cut
106              
107             sub get {
108 0     0 1 0 my $self = shift;
109 0         0 my $connection = shift;
110 0   0     0 my $query_data = shift // {};
111 0         0 my %opt = @_;
112 0   0     0 my $class = ref($self) || $self;
113 0 0 0     0 die 'usage: '.$class.'->get($connection, [$query_data], %opt)'
114             unless $connection && $connection->can('GET');
115              
116 0         0 my $path = $opt{path};
117             $path ||= $query_data->{id}
118             ? $class->resource . '/' . $query_data->{id}
119 0 0 0     0 : $class->resource;
120 0 0       0 if (keys %$query_data) {
121 0         0 $path .= $connection->client->buildQuery($class->TO_JSON($query_data));
122             }
123 0         0 my $msg = $connection->GET($path);
124             $msg->{results}
125 0         0 ? map { $class->construct($_, connection => $connection, parent => $opt{parent}) } @{ $msg->{results} }
  0         0  
126 0 0       0 : $class->construct($msg, connection => $connection, parent => $opt{parent});
127             }
128              
129             =head2 delete
130              
131             Deletes an object from the server
132              
133             $session->delete;
134              
135             =cut
136              
137             sub delete {
138 0     0 1 0 my $self = shift;
139 0   0     0 my $connection = shift
140             || $self->connection
141             || die 'Not connected';
142 0   0     0 my $data = shift || {id => $self->id};
143 0         0 my $path = $self->resource;
144 0         0 $connection->DELETE($path, $data);
145             }
146              
147             =head2 find_or_create
148              
149             Attempts a C on the object. If that fails, creates an new object on the server.
150              
151             =cut
152              
153             sub find_or_create {
154 0     0 1 0 my $class = shift;
155 0         0 my $connection = shift;
156 0         0 my $data = shift;
157              
158 0         0 my $param_types = $class->query_params;
159 0         0 my $prop_types = $class->_property_types;
160 0         0 my %query;
161             my %body;
162              
163 0         0 for my $fld (keys %$data) {
164 0         0 my $val = $data->{$fld};
165 0 0       0 if (exists $param_types->{$fld}) {
    0          
166 0         0 $query{$fld} = $val;
167             }
168             elsif (exists $prop_types->{$fld}) {
169 0         0 $body{$fld} = $val;
170             }
171             else {
172 0         0 warn "$class: ignoring unknown field: $fld";
173             }
174             }
175 0         0 my @recs = $class->get($connection, \%query);
176 0         0 my $rec;
177 0 0       0 if (@recs) {
178 0 0       0 warn "$class: ambiguous find_or_create query: @{[ keys %query ]}\n"
  0         0  
179             if @recs > 1;
180 0         0 $rec = $recs[0];
181 0         0 for (keys %body) {
182 0         0 $rec->$_($body{$_});
183             }
184             }
185             else {
186 0         0 $rec = $class->post($connection => $data);
187             }
188 0         0 $rec;
189             }
190              
191             =head2 path
192              
193             Computes a RESTful resource path for the object.
194              
195             =cut
196              
197             sub path {
198 0     0 1 0 my $self = shift;
199 0         0 my %opt = @_;
200 0         0 my $parent = $opt{parent};
201 0 0 0     0 $parent ||= $self->parent
202             if ref($self);
203 0         0 my $path = '';
204 0 0       0 $path .= $parent->path . '/'
205             if $parent;
206 0         0 $path .= $self->resource;
207 0   0     0 my $id = ref $self && $self->id;
208 0 0       0 $path .= '/' . $id if $id;
209 0         0 $path;
210             }
211              
212             =head2 parent
213              
214             Returns any parent class for the object. May be used to compute the path.
215              
216             =cut
217            
218 0     0 1 0 sub parent { shift->_parent(@_)}
219              
220             =head2 changed
221              
222             Returns a list of fields that have been updated since the
223             object was last saved via a `patch`, or `post`, or fetched
224             via a `get`.
225              
226             =cut
227              
228             sub changed {
229 0     0 1 0 my $self = shift;
230 0         0 my @changed;
231              
232 0 0       0 if (my $old_data = $self->_db_data) {
233 0         0 my $types = $self->_property_types;
234 0         0 my $data = $self->_raw_data;
235             # include only key and changed data
236 0         0 for my $fld (sort keys %$data) {
237             # ignore time-stamps
238 0 0       0 next if $fld =~ /^(id|modified|created)$/;
239 0         0 my $new_val = $data->{$fld};
240 0         0 my $old_val = $old_data->{$fld};
241             push @changed, $fld
242             if !defined($old_val)
243 0 0 0     0 || $self->_compare($types->{$fld}, $old_val, $new_val);
244             }
245             }
246 0         0 @changed;
247             }
248              
249             sub _compare {
250 0     0   0 my $self = shift;
251 0         0 my $type = shift;
252 0         0 my $v1 = shift;
253 0         0 my $v2 = shift;
254             $type eq 'Bool'
255             ? ($v1? 1: 0) != ($v2? 1 : 0)
256             : ($type eq 'Date'
257 0 0       0 ? do { abs($v1 - $v2) > 1 } # allow for rounding
  0 0       0  
    0          
    0          
258             : !Compare($v1, $v2));
259             }
260              
261             sub _pending_updates {
262 0     0   0 my $self = shift;
263 0         0 my $data = $self->_raw_data;
264 0         0 my %pending;
265 0         0 @pending{ $self->changed } = undef;
266             # pass the primary key
267 0         0 $pending{id} = undef;
268 0         0 my %updates = map { $_ => $data->{$_} } (sort keys %pending);
  0         0  
269 0         0 \%updates;
270             }
271              
272             =head2 connection
273              
274             Returns the connection associated with the object. Will be set if
275             the object has been fetched via a `get`, added via a `post` or updated via a `patch`.
276              
277             =cut
278              
279 0     0 1 0 sub connection { shift->_connection(@_)}
280              
281              
282             =head1 Internal METHODS
283              
284             =cut
285            
286             =head2 query_params
287              
288             __PACKAGE__->query_params(
289             name => 'Str',
290             extId => 'Str',
291             );
292              
293             This is used to specify any additional payload fields that may be
294             passed as query parameters, or returned along with object data.
295              
296             =cut
297              
298             sub query_params {
299 12     12 1 33 my ($entity_class, %params) = @_;
300              
301 12         28 for (keys %params) {
302 28         141 $entity_class->_query_params->{$_} = $params{$_};
303             }
304              
305 12         56 return $entity_class->_query_params;
306             }
307              
308             sub _property_types {
309 15     15   628 my $class = shift;
310 15         64 my $types = $class->_types;
311 15 100       88 unless ($types) {
312 4         13 my $meta = $class->meta;
313 4         79 my @atts = grep { $_ !~ /^_/ } ($meta->get_attribute_list);
  63         88  
314              
315             $types = {
316 4         7 map {$_ => $meta->get_attribute($_)->{type_constraint}} @atts
  63         181  
317             };
318 4         34 $class->_types($types);
319             }
320 15         114 $types;
321             }
322              
323             =head2 freeze
324              
325             Serializes an object to JSON., with data conversion.
326              
327             =over 4
328              
329             =item Dates are converted from numeric Unix timestamps to date-strings
330              
331             =item Booleans are converted from numeric (0, 1) to 'true', or 'false'.
332              
333             =item Nested objects are recursively serialized.
334              
335             =back
336              
337             =cut
338              
339             sub _freeze {
340 3     3   9137 my $self = shift;
341 3         15 my $frozen = $self->TO_JSON(@_);
342 3         12 to_json $frozen, { convert_blessed => 1};
343             }
344              
345             sub _raw_data {
346 5     5   6 my $self = shift;
347 5         7 my $types = $self->_property_types;
348 18         36 my %data = (map { $_ => $self->$_ }
349 5         16 grep { defined $self->$_ }
  76         128  
350             (keys %$types));
351 5         20 \%data;
352             }
353              
354             sub TO_JSON {
355 5     5 0 58 my $self = shift;
356 5         11 my $prop_types = $self->_property_types;
357 5         12 my $param_types = $self->query_params;
358 5   33     42 my $data = shift || $self->_raw_data;
359              
360 5         5 my %frozen;
361              
362 5         10 for my $fld (keys %$data) {
363 18   0     52 my $type = $prop_types->{$fld} || $param_types->{$fld} || do {
364             warn((ref($self) || $self).": unknown field/query-parameter: $fld");
365             'Str'
366             };
367            
368 18         18 my $val = $data->{$fld};
369 18 50       45 $frozen{$fld} = Bb::Collaborate::Ultra::Util::_freeze($val, $type)
370             if defined $val;
371             }
372 5         28 \%frozen;
373             }
374              
375             =head2 thaw
376              
377             The reverse of `freeze`. Deserializes JSON data to objects, with conversion of dates, boolean values or nested objects.
378              
379             =cut
380              
381             sub _thaw {
382 4     4   5 my $self = shift;
383 4         4 my $data = shift;
384 4         7 my $types = $self->_property_types;
385 4         3 my %thawed;
386              
387 4         11 for my $fld (keys %$data) {
388 24 50       52 if (exists $types->{$fld}) {
389 24         23 my $val = $data->{$fld};
390 24 50       59 $thawed{$fld} = Bb::Collaborate::Ultra::Util::_thaw($val, $types->{$fld})
391             if defined $val;
392             }
393             else {
394 0   0     0 my $class = ref($self) || $self;
395 0         0 warn $class." ignoring field: $fld";
396             }
397             }
398 4         14 \%thawed;
399             }
400              
401             =head2 construct
402              
403             Constructs a new object from server data.
404              
405             =cut
406              
407             sub construct {
408 0     0 1 0 my $class = shift;
409 0         0 my $payload = shift;
410 0         0 my %opt = @_;
411 0         0 my $data = $class->_thaw($payload);
412 0         0 my $obj = $class->new($data);
413 0         0 for ($opt{connection}) {
414 0 0       0 $obj->connection($_) if $_
415             }
416 0         0 for ($opt{parent}) {
417 0 0       0 $obj->parent($_) if $_;
418             }
419             # make a copy, so we can detect updates
420 0         0 $obj->_db_data(Clone::clone $data);
421 0         0 $obj;
422             }
423              
424             =head2 load_schema
425              
426             Constructs the object class from JSON schema data
427              
428             =cut
429              
430             sub load_schema {
431 14     14 1 20 my $class = shift;
432 14         105 my $data = join("", @_);
433 14         37 my $schema = from_json($data);
434             my $properties = $schema->{properties}
435 14 50       736 or die 'schema has no properties';
436              
437 14         114 foreach my $prop (sort keys %$properties) {
438 201 100       15548 next if $class->meta->get_attribute($prop);
439 192         1487 my $prop_spec = $properties->{$prop};
440 192         301 my $isa = $class->_build_isa( $prop, $prop_spec);
441 192 100       381 my $required = $prop_spec->{required} ? 1 : 0;
442 192         352 $class->meta->add_attribute(
443             $prop => (isa => $isa, is => 'rw', required => $required),
444             );
445             }
446             }
447              
448             sub _build_isa {
449 195     195   242 my $class = shift;
450 195         127 my $prop = shift;
451 195         160 my $prop_spec = shift;
452 195         117 my $isa;
453             my $type = $prop_spec->{type}
454 195 50       339 or die "property has no type: $prop";
455 195 100       378 if ($type eq 'array') {
    100          
456 3         14 my $of_type = $class->_build_isa($prop, $prop_spec->{items});
457 3         12 $isa = 'ArrayRef[' . $of_type . ']';
458             }
459             elsif (my $enum = $prop_spec->{enum}) {
460 33         36 my @enum = map { lc } (@$enum);
  124         167  
461             # create an anonymous enumeration
462 33         89 my $enum_name = 'enum_' . join('_', @enum);
463 33   66     149 $isa = $enums{$enum_name} ||= Mouse::Util::TypeConstraints::enum( $enum_name, \@enum);
464             }
465             else {
466             $isa = {string => 'Str',
467             boolean => 'Bool',
468             integer => 'Int',
469             object => 'Object',
470 159 50       492 }->{$type}
471             or die "unknown type: $type";
472 159 50 33     622 if ($isa eq 'Object' || $isa eq 'Array') {
473 0         0 warn "unknown $prop object. Predeclare in $class?";
474             }
475             }
476 195         2087 my $format = $prop_spec->{format};
477 195 100 66     346 $isa = 'Date' if $format && $format eq 'DATE_TIME';
478 195         233 $isa;
479             }
480              
481             #
482             # Shared subtypes
483             #
484             BEGIN {
485 6     6   37 use Mouse::Util::TypeConstraints;
  6         7  
  6         45  
486              
487             subtype 'Date'
488             => as 'Num'
489 11         173 => where {m{^\d+(\.\d*)?$}}
490 6     6   900 => message {"invalid date: $_"};
  0         0  
491             }
492              
493             =head1 LICENSE AND COPYRIGHT
494              
495             Copyright 2016 David Warring.
496              
497             This program is free software; you can redistribute it and/or modify it
498             under the terms of either: the GNU General Public License as published
499             by the Free Software Foundation; or the Artistic License.
500              
501             See http://dev.perl.org/licenses/ for more information.
502              
503              
504             =cut
505              
506             1;