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   246 use strict;
  49         69  
  49         1390  
4 49     49   195 use warnings;
  49         84  
  49         2821  
5             our $VERSION = '0.1702';
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   18598 use Carp;
  49         119  
  49         3056  
23 49     49   20379 use Module::Load qw( load );
  49         58846  
  49         322  
24 49     49   3491 use Scalar::Util qw( looks_like_number reftype );
  49         72  
  49         2522  
25 49     49   20746 use Sub::Quote qw( quote_sub );
  49         313249  
  49         3270  
26              
27 49     49   20106 use Resource::Silo::Metadata::DAG;
  49         186  
  49         56633  
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   477 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 147 my ($class, $target) = @_;
49 56         1126 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 246 my $self = shift;
93 156         268 my $name = shift;
94 156 100       519 if (@_ % 2) {
95 38         68 my $init = pop @_;
96 38         101 unshift @_, init => $init;
97             }
98 156         533 my (%spec) = @_;
99 156         448 my $target = $self->{target};
100              
101 156 100 100     2117 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       504 if defined $self->{resource}{$name};
105 150 100       1936 croak "resource: attempt to replace existing method '$name' in $target"
106             if $target->can($name);
107              
108 148         391 my @extra = grep { !$known_args{$_} } keys %spec;
  300         701  
109 148 100       343 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       370 if exists $spec{ignore_cache};
114              
115             carp "'loose_deps' is deprecated and has no effect"
116 147 50       344 if delete $spec{loose_deps};
117              
118             {
119             # validate 'require' before 'class'
120 147 100       187 if (!ref $spec{require}) {
  147         373  
121 142 100       433 $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       486 unless ref $spec{require} eq 'ARRAY';
125 146         225 my @bad = grep { $_ !~ $MOD_REX } @{ $spec{require} };
  9         69  
  146         296  
126             croak "resource '$name': 'require' doesn't look like module name(s): "
127 146 100       344 .join ", ", map { "'$_'" } @bad
  2         23  
128             if @bad;
129             };
130              
131 145 100       318 if (defined (my $value = $spec{literal})) {
132             defined $spec{$_}
133             and croak "resource '$name': 'literal' is incompatible with '$_'"
134 4   66     41 for qw( init class argument );
135 2     1   14 $spec{init} = sub { $value };
  1         3  
136 2   50     15 $spec{dependencies} //= [];
137 2   50     31 $spec{derived} //= 1;
138 2   50     15 $spec{cleanup_order} //= 9 ** 9 ** 9;
139             };
140              
141             _make_init_class($self, $name, \%spec)
142 143 100       375 if (defined $spec{class});
143              
144 137 100       391 if (my $deps = delete $spec{dependencies}) {
145 20 100       89 croak "resource '$name': 'dependencies' must be an array"
146             unless ref $deps eq 'ARRAY';
147 19         41 my @bad = grep { !/$ID_REX/ } @$deps;
  18         105  
148             croak "resource '$name': illegal dependency name(s): "
149 19 100       48 .join ", ", map { "'$_'" } @bad
  2         25  
150             if @bad;
151 17         57 $spec{allowdeps} = { map { $_ => 1 } @$deps };
  16         77  
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       321 if ($spec{preload}) {
158 3 100       8 if (defined $spec{argument}) {
159             croak "resource '$name': 'preload' must be an array of strings if 'argument' is specified"
160 1 50 33     4 unless ref $spec{preload} eq 'ARRAY' and !grep {ref $_} @{$spec{preload}};
  2         4  
  1         3  
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     427 if (!defined $spec{argument}) {
    100          
    100          
167 118         390 $spec{orig_argument} = '';
168 118         505 $spec{argument} = \&_is_empty;
169             } elsif (ref $spec{argument} eq $REGEXP) {
170 10         278 my $rex = qr(^(?:$spec{argument})$);
171 10         25 $spec{orig_argument} = $spec{argument};
172 10     31   38 $spec{argument} = sub { $_[0] =~ $rex };
  31         173  
173             } elsif ((reftype $spec{argument} // '') eq $CODE) {
174             # do nothing, we're fine
175             } else {
176 1         17 croak "resource '$name': 'argument' must be a regexp or a function";
177             }
178              
179 130   100     1312 $spec{cleanup_order} //= 0;
180             croak "resource '$name': 'cleanup_order' must be a number"
181 130 100       714 unless looks_like_number($spec{cleanup_order});
182              
183             croak "resource '$name': 'check' must be a function"
184 129 100 100     626 if defined $spec{check} and (reftype $spec{check} // '') ne $CODE;
      100        
185             croak "resource '$name': 'coerce' must be a function"
186 127 100 100     378 if defined $spec{coerce} and (reftype $spec{coerce} // '') ne $CODE;
      100        
187             croak "resource '$name': 'cleanup' must be a function"
188 126 100 100     546 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     413 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     313 if $spec{fork_cleanup} and $spec{fork_safe};
193              
194 121   100     559 $spec{fork_cleanup} //= $spec{cleanup};
195              
196 121         10648 $spec{origin} = Carp::shortmess("declared");
197 121         16707 $spec{origin} =~ s/\D+$//s;
198              
199 16 100       117 my @forward_deps = grep { !$self->{resource}{$_} || $self->{pending_deps}->contains($_) }
200 121 100       226 keys %{ $spec{allowdeps} || {} };
  121         626  
201 121 100       319 if (@forward_deps) {
202 7         36 my $loop = $self->{pending_deps}->find_loop($name, \@forward_deps);
203 7 100       22 if ($loop) {
204             my $msg = "resource '$name': circular dependency detected: ".
205 1         4 join " -> ", map { $self->elaborate_name($_) } @$loop;
  4         11  
206 1         15 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   475 no strict 'refs'; ## no critic Strictures
  49         92  
  49         64954  
  120         157  
214 120         541 *{"${target}::$name"} =
  120         668  
215             Resource::Silo::Container::_silo_make_accessor($name, \%spec);
216             }
217              
218 120 100       313 if (@forward_deps) {
219 6         28 $self->{pending_deps}->add_edges([$name], \@forward_deps);
220             } else {
221             # resource is independent, notify dependents if any
222 114         632 $self->{pending_deps}->drop_sink_cascade($name);
223             };
224 120         297 $self->{resource}{$name} = \%spec;
225 120 100       319 push @{ $self->{preload} }, $name if $spec{preload};
  3         8  
226              
227 120         401 return $self;
228             };
229              
230             sub _make_init_class {
231 8     8   15 my ($self, $name, $spec) = @_;
232              
233 8         15 my $class = $spec->{class};
234 8   100     18 $spec->{dependencies} //= {};
235              
236 8 100       74 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       27 unless ref $spec->{dependencies} eq 'HASH';
242              
243 5         7 my %deps = %{ $spec->{dependencies} };
  5         17  
244              
245 5         8 push @{ $spec->{require} }, $class;
  5         14  
246              
247 5         9 my %pass_args;
248             my @realdeps;
249 5         15 my @body = ("my \$c = shift;", "$class->new(" );
250              
251             # format: constructor_arg => [ resource_name, resource_arg ]
252 5         8 foreach my $key (keys %deps) {
253 7         11 my $entry = $deps{$key};
254              
255 7 100       19 if (ref $entry eq 'SCALAR') {
256             # pass a literal value to the constructor
257 1         2 $pass_args{$key} = $$entry;
258 1         9 next;
259             };
260              
261 6 100 66     72 if (defined $entry and !ref $entry) {
262             # allow bareword, and alias `foo => 1` to `foo => ['foo']
263 1 50       3 $entry = $key if $entry eq '1';
264 1         2 $entry = [ $entry ];
265             };
266 6 100 100     109 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         25 push @realdeps, $entry->[0];
273              
274 3 100 100     22 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       6 push @body, "\t\%pass_args"
280             if %pass_args;
281 2         5 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       26 );
292 2         282 $spec->{dependencies} = \@realdeps;
293             };
294              
295             sub _make_dsl {
296 56     56   114 my $inst = shift;
297 56     156   393 return sub { $inst->add(@_) };
  156         6485620  
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 966 my $self = shift;
313 6         14 my @list = sort keys %{ $self->{resource} };
  6         40  
314 6 100       57 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 3099 my ($self, $name) = @_;
327              
328 6         18 my $all = $self->{resource};
329 6         14 my $spec = $all->{$name};
330 6 100       35 croak "Unknown resource '$name'"
331             unless $spec;
332              
333 5         117 my %show = %$spec; # shallow copy
334              
335 5 100       22 if (my $deps = delete $show{allowdeps}) {
336 2         7 $show{dependencies} = [ keys %$deps ];
337             };
338              
339 5 50       16 if (exists $show{orig_argument}) {
340 5         13 $show{argument} = delete $show{orig_argument};
341             };
342              
343 5         19 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 6 my $self = shift;
364              
365 4         7 my $res = $self->{resource};
366 4         18 foreach my $name (sort keys %$res) {
367 9         13 my $entry = $res->{$name};
368              
369 9         11 foreach my $mod ( @{ $entry->{require} } ) {
  9         20  
370 2 100       3 eval { load $mod; 1 }
  2         8  
  1         95  
371             or croak "resource '$name': failed to load '$mod': $@";
372             };
373             };
374              
375 3         11 return $self;
376             };
377              
378             =head2 run_pending_checks
379              
380             =cut
381              
382             sub run_pending_checks {
383 74     74 1 139 my $self = shift;
384              
385 74         402 my @unsatisfied = $self->{pending_deps}->list_sinks;
386              
387 74 100       287 if (@unsatisfied) {
388             # TODO even more elaborate error message
389             my @wanted_by =
390 3         11 map { $self->elaborate_name($_) }
391 3         13 $self->{pending_deps}->list_predecessors(\@unsatisfied);
392 3         15 my $msg = "Unsatisfied dependencies ("
393             . join (", ", @unsatisfied)
394             . ") required by ("
395             . join (", ", @wanted_by)
396             . ")";
397 3         35 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         17 my $res = $self->{resource}{$name};
415 8 100 66     54 return "'$name'" unless $res && $res->{origin};
416 7         35 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;