File Coverage

blib/lib/Resource/Silo/Metadata.pm
Criterion Covered Total %
statement 176 176 100.0
branch 97 102 95.1
condition 62 72 86.1
subroutine 21 21 100.0
pod 7 7 100.0
total 363 378 96.0


line stmt bran cond sub pod time code
1             package Resource::Silo::Metadata;
2              
3 49     49   356 use strict;
  49         105  
  49         1816  
4 49     49   246 use warnings;
  49         94  
  49         25048  
5             our $VERSION = '0.1703';
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 49     49   393 use Carp;
  49         8647  
  49         4352  
23 49     49   28399 use Module::Load qw( load );
  49         84817  
  49         353  
24 49     49   4659 use Scalar::Util qw( looks_like_number reftype );
  49         98  
  49         3423  
25 49     49   27127 use Sub::Quote qw( quote_sub );
  49         438753  
  49         4585  
26              
27 49     49   29770 use Resource::Silo::Metadata::DAG;
  49         346  
  49         77535  
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 127     127   612 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 56     56 1 180 my ($class, $target) = @_;
49 56         903 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             coerce => 1,
77             dependencies => 1,
78             derived => 1,
79             cleanup => 1,
80             cleanup_order => 1,
81             fork_cleanup => 1,
82             fork_safe => 1,
83             ignore_cache => 1, # deprecated but has a special error message
84             init => 1,
85             literal => 1,
86             loose_deps => 1, # deprecated, noop + warning
87             nullable => 1,
88             preload => 1,
89             require => 1,
90             );
91             sub add {
92 156     156 1 368 my $self = shift;
93 156         399 my $name = shift;
94 156 100       658 if (@_ % 2) {
95 38         76 my $init = pop @_;
96 38         122 unshift @_, init => $init;
97             }
98 156         707 my (%spec) = @_;
99 156         800 my $target = $self->{target};
100              
101 156 100 100     2690 croak "resource: name must be an identifier"
      100        
102             unless defined $name and !ref $name and $name =~ $ID_REX;
103             croak "resource: attempt to redefine resource" . $self->elaborate_name($name)
104 151 100       634 if defined $self->{resource}{$name};
105 150 100       2455 croak "resource: attempt to replace existing method '$name' in $target"
106             if $target->can($name);
107              
108 148         580 my @extra = grep { !$known_args{$_} } keys %spec;
  300         996  
109 148 100       512 croak "resource '$name': unknown arguments in specification: @extra"
110             if @extra;
111              
112             croak "'ignore_cache' is deprecated. Use a simple method instead"
113 147 50       467 if exists $spec{ignore_cache};
114              
115             carp "'loose_deps' is deprecated and has no effect"
116 147 50       3553 if delete $spec{loose_deps};
117              
118             {
119             # validate 'require' before 'class'
120 147 100       273 if (!ref $spec{require}) {
  147         456  
121 142 100       606 $spec{require} = defined $spec{require} ? [ $spec{require} ] : [];
122             };
123             croak "resource '$name': 'require' must be a module name or a list thereof"
124 147 100       605 unless ref $spec{require} eq 'ARRAY';
125 146         260 my @bad = grep { $_ !~ $MOD_REX } @{ $spec{require} };
  9         100  
  146         630  
126             croak "resource '$name': 'require' doesn't look like module name(s): "
127 146 100       501 .join ", ", map { "'$_'" } @bad
  2         22  
128             if @bad;
129             };
130              
131 145 100       508 if (defined (my $value = $spec{literal})) {
132             defined $spec{$_}
133             and croak "resource '$name': 'literal' is incompatible with '$_'"
134 4   66     53 for qw( init class argument );
135 2     1   14 $spec{init} = sub { $value };
  1         3  
136 2   50     44 $spec{dependencies} //= [];
137 2   50     22 $spec{derived} //= 1;
138 2   50     17 $spec{cleanup_order} //= 9 ** 9 ** 9;
139             };
140              
141             _make_init_class($self, $name, \%spec)
142 143 100       577 if (defined $spec{class});
143              
144 137 100       583 if (my $deps = delete $spec{dependencies}) {
145 20 100       78 croak "resource '$name': 'dependencies' must be an array"
146             unless ref $deps eq 'ARRAY';
147 19         114 my @bad = grep { !/$ID_REX/ } @$deps;
  18         119  
148             croak "resource '$name': illegal dependency name(s): "
149 19 100       61 .join ", ", map { "'$_'" } @bad
  2         33  
150             if @bad;
151 17         39 $spec{allowdeps} = { map { $_ => 1 } @$deps };
  16         62  
152             };
153              
154             croak "resource '$name': 'init' must be a function"
155 134 100 100     949 unless ref $spec{init} and reftype $spec{init} eq $CODE;
156              
157 131 100       840 if ($spec{preload}) {
158 3 100       13 if (defined $spec{argument}) {
159             croak "resource '$name': 'preload' must be an array of strings if 'argument' is specified"
160 1 50 33     6 unless ref $spec{preload} eq 'ARRAY' and !grep {ref $_} @{$spec{preload}};
  2         11  
  1         4  
161             } else {
162 2         7 $spec{preload} = [undef]; # a dummy argument so that preload itself has unified code
163             }
164             };
165              
166 131 100 100     851 if (!defined $spec{argument}) {
    100          
    100          
167 118         1046 $spec{orig_argument} = '';
168 118         442 $spec{argument} = \&_is_empty;
169             } elsif (ref $spec{argument} eq $REGEXP) {
170 10         271 my $rex = qr(^(?:$spec{argument})$);
171 10         70 $spec{orig_argument} = $spec{argument};
172 10     31   59 $spec{argument} = sub { $_[0] =~ $rex };
  31         258  
173             } elsif ((reftype $spec{argument} // '') eq $CODE) {
174             # do nothing, we're fine
175             } else {
176 1         16 croak "resource '$name': 'argument' must be a regexp or a function";
177             }
178              
179 130   100     824 $spec{cleanup_order} //= 0;
180             croak "resource '$name': 'cleanup_order' must be a number"
181 130 100       935 unless looks_like_number($spec{cleanup_order});
182              
183             croak "resource '$name': 'check' must be a function"
184 129 100 100     573 if defined $spec{check} and (reftype $spec{check} // '') ne $CODE;
      100        
185             croak "resource '$name': 'coerce' must be a function"
186 127 100 100     620 if defined $spec{coerce} and (reftype $spec{coerce} // '') ne $CODE;
      100        
187             croak "resource '$name': 'cleanup' must be a function"
188 126 100 100     660 if defined $spec{cleanup} and (reftype $spec{cleanup} // '') ne $CODE;
      100        
189             croak "resource '$name': 'fork_cleanup' must be a function"
190 124 100 100     575 if defined $spec{fork_cleanup} and (reftype $spec{fork_cleanup} // '') ne $CODE;
      100        
191             croak "resource '$name': 'fork_cleanup' and 'fork_safe' are mutually exclusive"
192 122 100 100     454 if $spec{fork_cleanup} and $spec{fork_safe};
193              
194 121   100     708 $spec{fork_cleanup} //= $spec{cleanup};
195              
196 121         18816 $spec{origin} = Carp::shortmess("declared");
197 121         20798 $spec{origin} =~ s/\D+$//s;
198              
199 16 100       101 my @forward_deps = grep { !$self->{resource}{$_} || $self->{pending_deps}->contains($_) }
200 121 100       301 keys %{ $spec{allowdeps} || {} };
  121         811  
201 121 100       457 if (@forward_deps) {
202 7         34 my $loop = $self->{pending_deps}->find_loop($name, \@forward_deps);
203 7 100       16 if ($loop) {
204             my $msg = "resource '$name': circular dependency detected: ".
205 1         2 join " -> ", map { $self->elaborate_name($_) } @$loop;
  4         7  
206 1         8 croak $msg;
207             }
208             }
209              
210             # Move code generation into Resource::Silo::Container
211             # so that exceptions via croak() are attributed correctly.
212             {
213 49     49   580 no strict 'refs'; ## no critic Strictures
  49         120  
  49         89606  
  120         327  
214 120         764 *{"${target}::$name"} =
  120         789  
215             Resource::Silo::Container::_silo_make_accessor($name, \%spec);
216             }
217              
218 120 100       393 if (@forward_deps) {
219 6         44 $self->{pending_deps}->add_edges([$name], \@forward_deps);
220             } else {
221             # resource is independent, notify dependents if any
222 114         884 $self->{pending_deps}->drop_sink_cascade($name);
223             };
224 120         422 $self->{resource}{$name} = \%spec;
225 120 100       439 push @{ $self->{preload} }, $name if $spec{preload};
  3         11  
226              
227 120         556 return $self;
228             };
229              
230             sub _make_init_class {
231 8     8   20 my ($self, $name, $spec) = @_;
232              
233 8         18 my $class = $spec->{class};
234 8   100     28 $spec->{dependencies} //= {};
235              
236 8 100       105 croak "resource '$name': 'class' doesn't look like a package name: '$class'"
237             unless $class =~ $MOD_REX;
238             defined $spec->{$_} and croak "resource '$name': 'class' is incompatible with '$_'"
239 7   66     51 for qw(init argument);
240             croak "resource '$name': 'class' requires 'dependencies' to be a hash"
241 6 100       34 unless ref $spec->{dependencies} eq 'HASH';
242              
243 5         10 my %deps = %{ $spec->{dependencies} };
  5         23  
244              
245 5         11 push @{ $spec->{require} }, $class;
  5         18  
246              
247 5         12 my %pass_args;
248             my @realdeps;
249 5         17 my @body = ("my \$c = shift;", "$class->new(" );
250              
251             # format: constructor_arg => [ resource_name, resource_arg ]
252 5         14 foreach my $key (keys %deps) {
253 7         16 my $entry = $deps{$key};
254              
255 7 100       29 if (ref $entry eq 'SCALAR') {
256             # pass a literal value to the constructor
257 1         18 $pass_args{$key} = $$entry;
258 1         4 next;
259             };
260              
261 6 100 66     31 if (defined $entry and !ref $entry) {
262             # allow bareword, and alias `foo => 1` to `foo => ['foo']
263 1 50       6 $entry = $key if $entry eq '1';
264 1         2 $entry = [ $entry ];
265             };
266 6 100 100     149 croak "resource '$name': dependency '$key' has wrong format"
      50        
      100        
267             unless (
268             ref $entry eq 'ARRAY'
269             and @$entry <= 2
270             and ($entry->[0] // '') =~ $ID_REX
271             );
272 3         9 push @realdeps, $entry->[0];
273              
274 3 100 100     24 push @body, length ($entry->[1] // '')
275             ? sprintf( "\t'%s' => \$c->%s('%s'),",
276             quotemeta $key, $entry->[0], quotemeta $entry->[1] )
277             : sprintf( "\t'%s' => \$c->%s,", quotemeta $key, $entry->[0] );
278             };
279 2 100       8 push @body, "\t\%pass_args"
280             if %pass_args;
281 2         4 push @body, ");";
282              
283             $spec->{init} = quote_sub(
284             "init_of_$name",
285             join( "\n", @body ),
286             (%pass_args ? { '%pass_args' => \%pass_args, } : {}),
287             {
288             no_install => 1,
289             package => $self->{target},
290             }
291 2 100       33 );
292 2         399 $spec->{dependencies} = \@realdeps;
293             };
294              
295             sub _make_dsl {
296 56     56   120 my $inst = shift;
297 56     156   348 return sub { $inst->add(@_) };
  156         9792031  
298             };
299              
300             =head2 list
301              
302             Returns a list (or arrayref in scalar context)
303             containing the names of known resources.
304              
305             The order is not guaranteed.
306              
307             B. Return value structure is subject to change.
308              
309             =cut
310              
311             sub list {
312 6     6 1 1324 my $self = shift;
313 6         14 my @list = sort keys %{ $self->{resource} };
  6         33  
314 6 100       96 return wantarray ? @list : \@list;
315             };
316              
317             =head2 show( $name )
318              
319             Returns a shallow copy of resource specification.
320              
321             B. Return value structure is subject to change.
322              
323             =cut
324              
325             sub show {
326 6     6 1 4417 my ($self, $name) = @_;
327              
328 6         25 my $all = $self->{resource};
329 6         13 my $spec = $all->{$name};
330 6 100       90 croak "Unknown resource '$name'"
331             unless $spec;
332              
333 5         33 my %show = %$spec; # shallow copy
334              
335 5 100       22 if (my $deps = delete $show{allowdeps}) {
336 2         5 $show{dependencies} = [ keys %$deps ];
337             };
338              
339 5 50       14 if (exists $show{orig_argument}) {
340 5         14 $show{argument} = delete $show{orig_argument};
341             };
342              
343 5         26 return \%show;
344             };
345              
346             =head2 preload()
347              
348             Check setup validity. Dies on errors, return C<$self> otherwise.
349              
350             The following checks are available so far:
351              
352             =over
353              
354             =item * modules required by I resources are loaded.
355              
356             =back
357              
358             B. Interface & performed checks may change in the future.
359              
360             =cut
361              
362             sub preload {
363 4     4 1 9 my $self = shift;
364              
365 4         11 my $res = $self->{resource};
366 4         25 foreach my $name (sort keys %$res) {
367 9         19 my $entry = $res->{$name};
368              
369 9         14 foreach my $mod ( @{ $entry->{require} } ) {
  9         29  
370 2 100       5 eval { load $mod; 1 }
  2         9  
  1         117  
371             or croak "resource '$name': failed to load '$mod': $@";
372             };
373             };
374              
375 3         17 return $self;
376             };
377              
378             =head2 run_pending_checks
379              
380             =cut
381              
382             sub run_pending_checks {
383 74     74 1 164 my $self = shift;
384              
385 74         557 my @unsatisfied = $self->{pending_deps}->list_sinks;
386              
387 74 100       363 if (@unsatisfied) {
388             # TODO even more elaborate error message
389             my @wanted_by =
390 3         14 map { $self->elaborate_name($_) }
391 3         17 $self->{pending_deps}->list_predecessors(\@unsatisfied);
392 3         20 my $msg = "Unsatisfied dependencies ("
393             . join (", ", @unsatisfied)
394             . ") required by ("
395             . join (", ", @wanted_by)
396             . ")";
397 3         46 croak $msg;
398             };
399             }
400              
401             =head2 elaborate_name( $name )
402              
403             Return a resource name with origin information if available, or just the name in single quotes.
404              
405             Might look like this:
406              
407             'my_resource' declared at My/Module.pm line 42
408              
409             =cut
410              
411             sub elaborate_name {
412 8     8 1 16 my ($self, $name) = @_;
413              
414 8         30 my $res = $self->{resource}{$name};
415 8 100 66     56 return "'$name'" unless $res && $res->{origin};
416 7         73 return "'$name' $res->{origin}";
417             }
418              
419             =head1 COPYRIGHT AND LICENSE
420              
421             Copyright (c) 2023-2026, Konstantin Uvarin, C<< >>
422              
423             This program is free software.
424             You can redistribute it and/or modify it under the terms of either:
425             the GNU General Public License as published by the Free Software Foundation,
426             or the Artistic License.
427              
428             See L for more information.
429              
430             =cut
431              
432             1;