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   268 use strict;
  49         80  
  49         23451  
4 49     49   213 use warnings;
  49         65  
  49         3499  
5             our $VERSION = '0.17';
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   276 use Carp;
  49         85  
  49         2958  
23 49     49   21025 use Module::Load qw( load );
  49         61155  
  49         326  
24 49     49   3506 use Scalar::Util qw( looks_like_number reftype );
  49         82  
  49         2586  
25 49     49   21427 use Sub::Quote qw( quote_sub );
  49         314078  
  49         3576  
26              
27 49     49   20315 use Resource::Silo::Metadata::DAG;
  49         175  
  49         59201  
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   436 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 177 my ($class, $target) = @_;
49 56         656 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 298 my $self = shift;
93 156         277 my $name = shift;
94 156 100       546 if (@_ % 2) {
95 38         69 my $init = pop @_;
96 38         125 unshift @_, init => $init;
97             }
98 156         513 my (%spec) = @_;
99 156         544 my $target = $self->{target};
100              
101 156 100 100     2352 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       495 if defined $self->{resource}{$name};
105 150 100       1939 croak "resource: attempt to replace existing method '$name' in $target"
106             if $target->can($name);
107              
108 148         444 my @extra = grep { !$known_args{$_} } keys %spec;
  300         776  
109 148 100       380 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       396 if exists $spec{ignore_cache};
114              
115             carp "'loose_deps' is deprecated and has no effect"
116 147 50       412 if delete $spec{loose_deps};
117              
118             {
119             # validate 'require' before 'class'
120 147 100       346 if (!ref $spec{require}) {
  147         388  
121 142 100       514 $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       492 unless ref $spec{require} eq 'ARRAY';
125 146         233 my @bad = grep { $_ !~ $MOD_REX } @{ $spec{require} };
  9         86  
  146         310  
126             croak "resource '$name': 'require' doesn't look like module name(s): "
127 146 100       479 .join ", ", map { "'$_'" } @bad
  2         19  
128             if @bad;
129             };
130              
131 145 100       364 if (defined (my $value = $spec{literal})) {
132             defined $spec{$_}
133             and croak "resource '$name': 'literal' is incompatible with '$_'"
134 4   66     48 for qw( init class argument );
135 2     1   13 $spec{init} = sub { $value };
  1         3  
136 2   50     14 $spec{dependencies} //= [];
137 2   50     36 $spec{derived} //= 1;
138 2   50     19 $spec{cleanup_order} //= 9 ** 9 ** 9;
139             };
140              
141             _make_init_class($self, $name, \%spec)
142 143 100       366 if (defined $spec{class});
143              
144 137 100       419 if (my $deps = delete $spec{dependencies}) {
145 20 100       111 croak "resource '$name': 'dependencies' must be an array"
146             unless ref $deps eq 'ARRAY';
147 19         41 my @bad = grep { !/$ID_REX/ } @$deps;
  18         108  
148             croak "resource '$name': illegal dependency name(s): "
149 19 100       50 .join ", ", map { "'$_'" } @bad
  2         44  
150             if @bad;
151 17         66 $spec{allowdeps} = { map { $_ => 1 } @$deps };
  16         57  
152             };
153              
154             croak "resource '$name': 'init' must be a function"
155 134 100 100     773 unless ref $spec{init} and reftype $spec{init} eq $CODE;
156              
157 131 100       317 if ($spec{preload}) {
158 3 100       10 if (defined $spec{argument}) {
159             croak "resource '$name': 'preload' must be an array of strings if 'argument' is specified"
160 1 50 33     5 unless ref $spec{preload} eq 'ARRAY' and !grep {ref $_} @{$spec{preload}};
  2         5  
  1         2  
161             } else {
162 2         5 $spec{preload} = [undef]; # a dummy argument so that preload itself has unified code
163             }
164             };
165              
166 131 100 100     492 if (!defined $spec{argument}) {
    100          
    100          
167 118         518 $spec{orig_argument} = '';
168 118         480 $spec{argument} = \&_is_empty;
169             } elsif (ref $spec{argument} eq $REGEXP) {
170 10         333 my $rex = qr(^(?:$spec{argument})$);
171 10         31 $spec{orig_argument} = $spec{argument};
172 10     31   83 $spec{argument} = sub { $_[0] =~ $rex };
  31         236  
173             } elsif ((reftype $spec{argument} // '') eq $CODE) {
174             # do nothing, we're fine
175             } else {
176 1         12 croak "resource '$name': 'argument' must be a regexp or a function";
177             }
178              
179 130   100     911 $spec{cleanup_order} //= 0;
180             croak "resource '$name': 'cleanup_order' must be a number"
181 130 100       802 unless looks_like_number($spec{cleanup_order});
182              
183             croak "resource '$name': 'check' must be a function"
184 129 100 100     448 if defined $spec{check} and (reftype $spec{check} // '') ne $CODE;
      100        
185             croak "resource '$name': 'coerce' must be a function"
186 127 100 100     465 if defined $spec{coerce} and (reftype $spec{coerce} // '') ne $CODE;
      100        
187             croak "resource '$name': 'cleanup' must be a function"
188 126 100 100     529 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     417 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     311 if $spec{fork_cleanup} and $spec{fork_safe};
193              
194 121   100     623 $spec{fork_cleanup} //= $spec{cleanup};
195              
196 121         11239 $spec{origin} = Carp::shortmess("declared");
197 121         17657 $spec{origin} =~ s/\D+$//s;
198              
199 16 100       88 my @forward_deps = grep { !$self->{resource}{$_} || $self->{pending_deps}->contains($_) }
200 121 100       227 keys %{ $spec{allowdeps} || {} };
  121         738  
201 121 100       379 if (@forward_deps) {
202 7         31 my $loop = $self->{pending_deps}->find_loop($name, \@forward_deps);
203 7 100       14 if ($loop) {
204             my $msg = "resource '$name': circular dependency detected: ".
205 1         3 join " -> ", map { $self->elaborate_name($_) } @$loop;
  4         6  
206 1         7 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   527 no strict 'refs'; ## no critic Strictures
  49         86  
  49         69523  
  120         176  
214 120         600 *{"${target}::$name"} =
  120         580  
215             Resource::Silo::Container::_silo_make_accessor($name, \%spec);
216             }
217              
218 120 100       338 if (@forward_deps) {
219 6         63 $self->{pending_deps}->add_edges([$name], \@forward_deps);
220             } else {
221             # resource is independent, notify dependents if any
222 114         664 $self->{pending_deps}->drop_sink_cascade($name);
223             };
224 120         308 $self->{resource}{$name} = \%spec;
225 120 100       331 push @{ $self->{preload} }, $name if $spec{preload};
  3         9  
226              
227 120         456 return $self;
228             };
229              
230             sub _make_init_class {
231 8     8   19 my ($self, $name, $spec) = @_;
232              
233 8         15 my $class = $spec->{class};
234 8   100     24 $spec->{dependencies} //= {};
235              
236 8 100       99 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     42 for qw(init argument);
240             croak "resource '$name': 'class' requires 'dependencies' to be a hash"
241 6 100       55 unless ref $spec->{dependencies} eq 'HASH';
242              
243 5         9 my %deps = %{ $spec->{dependencies} };
  5         17  
244              
245 5         11 push @{ $spec->{require} }, $class;
  5         12  
246              
247 5         10 my %pass_args;
248             my @realdeps;
249 5         12 my @body = ("my \$c = shift;", "$class->new(" );
250              
251             # format: constructor_arg => [ resource_name, resource_arg ]
252 5         11 foreach my $key (keys %deps) {
253 7         12 my $entry = $deps{$key};
254              
255 7 100       15 if (ref $entry eq 'SCALAR') {
256             # pass a literal value to the constructor
257 1         1 $pass_args{$key} = $$entry;
258 1         2 next;
259             };
260              
261 6 100 66     34 if (defined $entry and !ref $entry) {
262             # allow bareword, and alias `foo => 1` to `foo => ['foo']
263 1 50       11 $entry = $key if $entry eq '1';
264 1         3 $entry = [ $entry ];
265             };
266 6 100 100     80 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         5 push @realdeps, $entry->[0];
273              
274 3 100 100     16 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       4 push @body, "\t\%pass_args"
280             if %pass_args;
281 2         3 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       18 );
292 2         230 $spec->{dependencies} = \@realdeps;
293             };
294              
295             sub _make_dsl {
296 56     56   110 my $inst = shift;
297 56     156   241 return sub { $inst->add(@_) };
  156         6856082  
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 1181 my $self = shift;
313 6         21 my @list = sort keys %{ $self->{resource} };
  6         36  
314 6 100       52 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 4515 my ($self, $name) = @_;
327              
328 6         20 my $all = $self->{resource};
329 6         15 my $spec = $all->{$name};
330 6 100       38 croak "Unknown resource '$name'"
331             unless $spec;
332              
333 5         51 my %show = %$spec; # shallow copy
334              
335 5 100       40 if (my $deps = delete $show{allowdeps}) {
336 2         10 $show{dependencies} = [ keys %$deps ];
337             };
338              
339 5 50       22 if (exists $show{orig_argument}) {
340 5         16 $show{argument} = delete $show{orig_argument};
341             };
342              
343 5         24 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 7 my $self = shift;
364              
365 4         9 my $res = $self->{resource};
366 4         19 foreach my $name (sort keys %$res) {
367 9         14 my $entry = $res->{$name};
368              
369 9         12 foreach my $mod ( @{ $entry->{require} } ) {
  9         21  
370 2 100       3 eval { load $mod; 1 }
  2         7  
  1         97  
371             or croak "resource '$name': failed to load '$mod': $@";
372             };
373             };
374              
375 3         15 return $self;
376             };
377              
378             =head2 run_pending_checks
379              
380             =cut
381              
382             sub run_pending_checks {
383 74     74 1 134 my $self = shift;
384              
385 74         449 my @unsatisfied = $self->{pending_deps}->list_sinks;
386              
387 74 100       283 if (@unsatisfied) {
388             # TODO even more elaborate error message
389             my @wanted_by =
390 3         11 map { $self->elaborate_name($_) }
391 3         33 $self->{pending_deps}->list_predecessors(\@unsatisfied);
392 3         13 my $msg = "Unsatisfied dependencies ("
393             . join (", ", @unsatisfied)
394             . ") required by ("
395             . join (", ", @wanted_by)
396             . ")";
397 3         32 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         11 my $res = $self->{resource}{$name};
415 8 100 66     45 return "'$name'" unless $res && $res->{origin};
416 7         43 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;