File Coverage

blib/lib/Resource/Silo/Metadata.pm
Criterion Covered Total %
statement 170 170 100.0
branch 90 94 95.7
condition 56 64 87.5
subroutine 21 21 100.0
pod 7 7 100.0
total 344 356 96.6


line stmt bran cond sub pod time code
1             package Resource::Silo::Metadata;
2              
3 48     48   318 use strict;
  48         114  
  48         26925  
4 48     48   283 use warnings;
  48         87  
  48         4160  
5             our $VERSION = '0.16';
6              
7             =head1 NAME
8              
9             Resource::Silo::Metadata - resource container metadata for L.
10              
11             =head1 DESCRIPTION
12              
13             This class stores information about available resources in a specific
14             container class. Normally only used internally.
15              
16             See also L.
17              
18             =head1 METHODS
19              
20             =cut
21              
22 48     48   386 use Carp;
  48         131  
  48         3755  
23 48     48   25778 use Module::Load qw( load );
  48         77014  
  48         345  
24 48     48   4447 use Scalar::Util qw( looks_like_number reftype );
  48         93  
  48         3417  
25 48     48   26361 use Sub::Quote qw( quote_sub );
  48         431196  
  48         4370  
26              
27 48     48   26439 use Resource::Silo::Metadata::DAG;
  48         371  
  48         73088  
28              
29             # TODO make Carp recognise Moo's internals as internal
30             our @CARP_NOT = qw(Resource::Silo Resource::Silo::Container);
31              
32             my $BARE_REX = '[a-z][a-z_0-9]*';
33             my $ID_REX = qr(^$BARE_REX$)i;
34             my $MOD_REX = qr(^$BARE_REX(?:::$BARE_REX)*$)i;
35              
36             # Define possible reftypes portably
37             my $CODE = reftype sub { };
38             my $REGEXP = ref qr/.../;
39 123     123   456 sub _is_empty { $_[0] eq '' };
40              
41             =head2 new( $target )
42              
43             $target is the name of the module where resource access methods will be created.
44              
45             =cut
46              
47             sub new {
48 53     53 1 192 my ($class, $target) = @_;
49 53         860 return bless {
50             # package to work on
51             target => $target,
52              
53             # resources to load immediately upon startup
54             preload => [],
55              
56             # resource spec storage
57             resource => {},
58              
59             # tracking of forward dependencies:
60             pending_deps => Resource::Silo::Metadata::DAG->new,
61             }, $class;
62             };
63              
64             =head2 add( $resource_name, ... )
65              
66             Create resource type. See L for details.
67              
68             =cut
69              
70             # Alphabetical order, please
71             # TODO add types to the hash to simplify checks
72             my %known_args = (
73             argument => 1,
74             class => 1,
75             check => 1,
76             dependencies => 1,
77             derived => 1,
78             cleanup => 1,
79             cleanup_order => 1,
80             fork_cleanup => 1,
81             fork_safe => 1,
82             ignore_cache => 1, # deprecated but has a special error message
83             init => 1,
84             literal => 1,
85             loose_deps => 1, # deprecated, noop + warning
86             nullable => 1,
87             preload => 1,
88             require => 1,
89             );
90             sub add {
91 150     150 1 348 my $self = shift;
92 150         334 my $name = shift;
93 150 100       605 if (@_ % 2) {
94 38         81 my $init = pop @_;
95 38         148 unshift @_, init => $init;
96             }
97 150         641 my (%spec) = @_;
98 150         547 my $target = $self->{target};
99              
100 150 100 100     2538 croak "resource: name must be an identifier"
      100        
101             unless defined $name and !ref $name and $name =~ $ID_REX;
102             croak "resource: attempt to redefine resource" . $self->elaborate_name($name)
103 145 100       610 if defined $self->{resource}{$name};
104 144 100       2337 croak "resource: attempt to replace existing method '$name' in $target"
105             if $target->can($name);
106              
107 142         492 my @extra = grep { !$known_args{$_} } keys %spec;
  286         958  
108 142 100       447 croak "resource '$name': unknown arguments in specification: @extra"
109             if @extra;
110              
111             croak "'ignore_cache' is deprecated. Use a simple method instead"
112 141 50       406 if exists $spec{ignore_cache};
113              
114             carp "'loose_deps' is deprecated and has no effect"
115 141 50       661 if delete $spec{loose_deps};
116              
117             {
118             # validate 'require' before 'class'
119 141 100       272 if (!ref $spec{require}) {
  141         653  
120 136 100       622 $spec{require} = defined $spec{require} ? [ $spec{require} ] : [];
121             };
122             croak "resource '$name': 'require' must be a module name or a list thereof"
123 141 100       572 unless ref $spec{require} eq 'ARRAY';
124 140         296 my @bad = grep { $_ !~ $MOD_REX } @{ $spec{require} };
  9         88  
  140         381  
125             croak "resource '$name': 'require' doesn't look like module name(s): "
126 140 100       599 .join ", ", map { "'$_'" } @bad
  2         16  
127             if @bad;
128             };
129              
130 139 100       469 if (defined (my $value = $spec{literal})) {
131             defined $spec{$_}
132             and croak "resource '$name': 'literal' is incompatible with '$_'"
133 4   66     42 for qw( init class argument );
134 2     1   12 $spec{init} = sub { $value };
  1         3  
135 2   50     32 $spec{dependencies} //= [];
136 2   50     14 $spec{derived} //= 1;
137 2   50     12 $spec{cleanup_order} //= 9 ** 9 ** 9;
138             };
139              
140             _make_init_class($self, $name, \%spec)
141 137 100       423 if (defined $spec{class});
142              
143 131 100       391 if (my $deps = delete $spec{dependencies}) {
144 20 100       95 croak "resource '$name': 'dependencies' must be an array"
145             unless ref $deps eq 'ARRAY';
146 19         48 my @bad = grep { !/$ID_REX/ } @$deps;
  18         120  
147             croak "resource '$name': illegal dependency name(s): "
148 19 100       134 .join ", ", map { "'$_'" } @bad
  2         39  
149             if @bad;
150 17         71 $spec{allowdeps} = { map { $_ => 1 } @$deps };
  16         63  
151             };
152              
153             croak "resource '$name': 'init' must be a function"
154 128 100 100     887 unless ref $spec{init} and reftype $spec{init} eq $CODE;
155              
156 125 100 100     553 if (!defined $spec{argument}) {
    100          
    100          
157 113         374 $spec{orig_argument} = '';
158 113         369 $spec{argument} = \&_is_empty;
159             } elsif (ref $spec{argument} eq $REGEXP) {
160 9         280 my $rex = qr(^(?:$spec{argument})$);
161 9         57 $spec{orig_argument} = $spec{argument};
162 9     29   45 $spec{argument} = sub { $_[0] =~ $rex };
  29         169  
163             } elsif ((reftype $spec{argument} // '') eq $CODE) {
164             # do nothing, we're fine
165             } else {
166 1         17 croak "resource '$name': 'argument' must be a regexp or a function";
167             }
168              
169 124   100     802 $spec{cleanup_order} //= 0;
170             croak "resource '$name': 'cleanup_order' must be a number"
171 124 100       950 unless looks_like_number($spec{cleanup_order});
172              
173             croak "resource '$name': 'check' must be a function"
174 123 100 100     1131 if defined $spec{check} and (reftype $spec{check} // '') ne $CODE;
      100        
175             croak "resource '$name': 'cleanup' must be a function"
176 121 100 100     843 if defined $spec{cleanup} and (reftype $spec{cleanup} // '') ne $CODE;
      100        
177             croak "resource '$name': 'fork_cleanup' must be a function"
178 119 100 100     630 if defined $spec{fork_cleanup} and (reftype $spec{fork_cleanup} // '') ne $CODE;
      100        
179             croak "resource '$name': 'fork_cleanup' and 'fork_safe' are mutually exclusive"
180 117 100 100     410 if $spec{fork_cleanup} and $spec{fork_safe};
181              
182 116   100     592 $spec{fork_cleanup} //= $spec{cleanup};
183              
184 116 100       290 if ($spec{preload}) {
185 2         4 push @{ $self->{preload} }, $name;
  2         8  
186             };
187              
188 116         16617 $spec{origin} = Carp::shortmess("declared");
189 116         24312 $spec{origin} =~ s/\D+$//s;
190              
191 16 100       96 my @forward_deps = grep { !$self->{resource}{$_} || $self->{pending_deps}->contains($_) }
192 116 100       292 keys %{ $spec{allowdeps} || {} };
  116         851  
193 116 100       400 if (@forward_deps) {
194 7         55 my $loop = $self->{pending_deps}->find_loop($name, \@forward_deps);
195 7 100       21 if ($loop) {
196             my $msg = "resource '$name': circular dependency detected: ".
197 1         5 join " -> ", map { $self->elaborate_name($_) } @$loop;
  4         11  
198 1         16 croak $msg;
199             }
200             }
201              
202             # Move code generation into Resource::Silo::Container
203             # so that exceptions via croak() are attributed correctly.
204             {
205 48     48   522 no strict 'refs'; ## no critic Strictures
  48         130  
  48         83729  
  115         190  
206 115         674 *{"${target}::$name"} =
  115         714  
207             Resource::Silo::Container::_silo_make_accessor($name, \%spec);
208             }
209              
210 115 100       466 if (@forward_deps) {
211 6         34 $self->{pending_deps}->add_edges([$name], \@forward_deps);
212             } else {
213             # resource is independent, notify dependents if any
214 109         795 $self->{pending_deps}->drop_sink_cascade($name);
215             };
216 115         339 $self->{resource}{$name} = \%spec;
217              
218 115         489 return $self;
219             };
220              
221             sub _make_init_class {
222 8     8   18 my ($self, $name, $spec) = @_;
223              
224 8         13 my $class = $spec->{class};
225 8   100     37 $spec->{dependencies} //= {};
226              
227 8 100       63 croak "resource '$name': 'class' doesn't look like a package name: '$class'"
228             unless $class =~ $MOD_REX;
229             defined $spec->{$_} and croak "resource '$name': 'class' is incompatible with '$_'"
230 7   66     44 for qw(init argument);
231             croak "resource '$name': 'class' requires 'dependencies' to be a hash"
232 6 100       24 unless ref $spec->{dependencies} eq 'HASH';
233              
234 5         6 my %deps = %{ $spec->{dependencies} };
  5         13  
235              
236 5         7 push @{ $spec->{require} }, $class;
  5         12  
237              
238 5         7 my %pass_args;
239             my @realdeps;
240 5         17 my @body = ("my \$c = shift;", "$class->new(" );
241              
242             # format: constructor_arg => [ resource_name, resource_arg ]
243 5         10 foreach my $key (keys %deps) {
244 7         10 my $entry = $deps{$key};
245              
246 7 100       13 if (ref $entry eq 'SCALAR') {
247             # pass a literal value to the constructor
248 1         2 $pass_args{$key} = $$entry;
249 1         1 next;
250             };
251              
252 6 100 66     19 if (defined $entry and !ref $entry) {
253             # allow bareword, and alias `foo => 1` to `foo => ['foo']
254 1 50       4 $entry = $key if $entry eq '1';
255 1         3 $entry = [ $entry ];
256             };
257 6 100 100     99 croak "resource '$name': dependency '$key' has wrong format"
      50        
      100        
258             unless (
259             ref $entry eq 'ARRAY'
260             and @$entry <= 2
261             and ($entry->[0] // '') =~ $ID_REX
262             );
263 3         5 push @realdeps, $entry->[0];
264              
265 3 100 100     14 push @body, length ($entry->[1] // '')
266             ? sprintf( "\t'%s' => \$c->%s('%s'),",
267             quotemeta $key, $entry->[0], quotemeta $entry->[1] )
268             : sprintf( "\t'%s' => \$c->%s,", quotemeta $key, $entry->[0] );
269             };
270 2 100       5 push @body, "\t\%pass_args"
271             if %pass_args;
272 2         3 push @body, ");";
273              
274             $spec->{init} = quote_sub(
275             "init_of_$name",
276             join( "\n", @body ),
277             (%pass_args ? { '%pass_args' => \%pass_args, } : {}),
278             {
279             no_install => 1,
280             package => $self->{target},
281             }
282 2 100       34 );
283 2         201 $spec->{dependencies} = \@realdeps;
284             };
285              
286             sub _make_dsl {
287 53     53   154 my $inst = shift;
288 53     150   316 return sub { $inst->add(@_) };
  150         9087810  
289             };
290              
291             =head2 list
292              
293             Returns a list (or arrayref in scalar context)
294             containing the names of known resources.
295              
296             The order is not guaranteed.
297              
298             B. Return value structure is subject to change.
299              
300             =cut
301              
302             sub list {
303 6     6 1 1014 my $self = shift;
304 6         13 my @list = sort keys %{ $self->{resource} };
  6         37  
305 6 100       58 return wantarray ? @list : \@list;
306             };
307              
308             =head2 show( $name )
309              
310             Returns a shallow copy of resource specification.
311              
312             B. Return value structure is subject to change.
313              
314             =cut
315              
316             sub show {
317 6     6 1 6393 my ($self, $name) = @_;
318              
319 6         16 my $all = $self->{resource};
320 6         16 my $spec = $all->{$name};
321 6 100       39 croak "Unknown resource '$name'"
322             unless $spec;
323              
324 5         44 my %show = %$spec; # shallow copy
325              
326 5 100       25 if (my $deps = delete $show{allowdeps}) {
327 2         8 $show{dependencies} = [ keys %$deps ];
328             };
329              
330 5 50       22 if (exists $show{orig_argument}) {
331 5         15 $show{argument} = delete $show{orig_argument};
332             };
333              
334 5         20 return \%show;
335             };
336              
337             =head2 preload()
338              
339             Check setup validity. Dies on errors, return C<$self> otherwise.
340              
341             The following checks are available so far:
342              
343             =over
344              
345             =item * modules required by I resources are loaded.
346              
347             =back
348              
349             B. Interface & performed checks may change in the future.
350              
351             =cut
352              
353             sub preload {
354 4     4 1 10 my $self = shift;
355              
356 4         10 my $res = $self->{resource};
357 4         25 foreach my $name (sort keys %$res) {
358 8         18 my $entry = $res->{$name};
359              
360 8         16 foreach my $mod ( @{ $entry->{require} } ) {
  8         27  
361 2 100       4 eval { load $mod; 1 }
  2         11  
  1         182  
362             or croak "resource '$name': failed to load '$mod': $@";
363             };
364             };
365              
366 3         18 return $self;
367             };
368              
369             =head2 run_pending_checks
370              
371             =cut
372              
373             sub run_pending_checks {
374 70     70 1 148 my $self = shift;
375              
376 70         511 my @unsatisfied = $self->{pending_deps}->list_sinks;
377              
378 70 100       325 if (@unsatisfied) {
379             # TODO even more elaborate error message
380             my @wanted_by =
381 3         12 map { $self->elaborate_name($_) }
382 3         19 $self->{pending_deps}->list_predecessors(\@unsatisfied);
383 3         15 my $msg = "Unsatisfied dependencies ("
384             . join (", ", @unsatisfied)
385             . ") required by ("
386             . join (", ", @wanted_by)
387             . ")";
388 3         45 croak $msg;
389             };
390             }
391              
392             =head2 elaborate_name( $name )
393              
394             Return a resource name with origin information if available, or just the name in single quotes.
395              
396             Might look like this:
397              
398             'my_resource' declared at My/Module.pm line 42
399              
400             =cut
401              
402             sub elaborate_name {
403 8     8 1 20 my ($self, $name) = @_;
404              
405 8         20 my $res = $self->{resource}{$name};
406 8 100 66     51 return "'$name'" unless $res && $res->{origin};
407 7         43 return "'$name' $res->{origin}";
408             }
409              
410             =head1 COPYRIGHT AND LICENSE
411              
412             Copyright (c) 2023, Konstantin Uvarin, C<< >>
413              
414             This program is free software.
415             You can redistribute it and/or modify it under the terms of either:
416             the GNU General Public License as published by the Free Software Foundation,
417             or the Artistic License.
418              
419             See L for more information.
420              
421             =cut
422              
423             1;