File Coverage

blib/lib/GraphQL/Plugin/Convert/DBIC.pm
Criterion Covered Total %
statement 199 200 99.5
branch 76 92 82.6
condition 30 38 78.9
subroutine 25 25 100.0
pod 1 2 50.0
total 331 357 92.7


line stmt bran cond sub pod time code
1             package GraphQL::Plugin::Convert::DBIC;
2 5     5   5089 use strict;
  5         11  
  5         180  
3 5     5   30 use warnings;
  5         9  
  5         133  
4 5     5   2656 use GraphQL::Schema;
  5         8574017  
  5         316  
5 5     5   46 use GraphQL::Debug qw(_debug);
  5         10  
  5         261  
6 5     5   3453 use Lingua::EN::Inflect::Number qw(to_S to_PL);
  5         157213  
  5         46  
7 5     5   1357 use Carp qw(confess);
  5         14  
  5         382  
8              
9             our $VERSION = "0.16";
10 5     5   40 use constant DEBUG => $ENV{GRAPHQL_DEBUG};
  5         13  
  5         9515  
11              
12             my %GRAPHQL_TYPE2SQLS = (
13             String => [
14             'wlongvarchar',
15             'guid',
16             'uuid',
17             'wvarchar',
18             'wchar',
19             'longvarbinary',
20             'varbinary',
21             'binary',
22             'longvarchar',
23             'unknown_type',
24             'all_types',
25             'char',
26             'varchar',
27             'udt',
28             'udt_locator',
29             'row',
30             'ref',
31             'blob',
32             'blob_locator',
33             'clob',
34             'clob_locator',
35             'array',
36             'array_locator',
37             'multiset',
38             'multiset_locator',
39             # mysql
40             'set',
41             'text',
42             'tinytext',
43             'mediumtext',
44             'longtext',
45             # pgsql
46             'cidr',
47             'inet',
48             ],
49             Int => [
50             'bigint',
51             'bit',
52             'tinyint',
53             'integer',
54             'smallint',
55             'mediumint',
56             'interval',
57             'interval_year',
58             'interval_month',
59             'interval_day',
60             'interval_hour',
61             'interval_minute',
62             'interval_second',
63             'interval_year_to_month',
64             'interval_day_to_hour',
65             'interval_day_to_minute',
66             'interval_day_to_second',
67             'interval_hour_to_minute',
68             'interval_hour_to_second',
69             'interval_minute_to_second',
70             # not DBI SQL_* types
71             'int',
72             ],
73             Float => [
74             'numeric',
75             'decimal',
76             'float',
77             'real',
78             'double',
79             ],
80             DateTime => [
81             'datetime',
82             'date',
83             'time',
84             'timestamp',
85             'type_date',
86             'type_time',
87             'type_timestamp',
88             'type_time_with_timezone',
89             'type_timestamp_with_timezone',
90             # pgsql
91             'timestamp with time zone',
92             'timestamp without time zone',
93             ],
94             Boolean => [
95             'boolean',
96             ],
97             ID => [
98             'wvarchar',
99             ],
100             );
101             my %TYPEMAP = (
102             (map {
103             my $gql_type = $_;
104             map {
105             ($_ => $gql_type)
106             } @{ $GRAPHQL_TYPE2SQLS{$gql_type} }
107             } keys %GRAPHQL_TYPE2SQLS),
108             enum => sub {
109             my ($source, $column, $info) = @_;
110             my $extra = $info->{extra};
111             return {
112             kind => 'enum',
113             name => _dbicsource2pretty(
114             $extra->{custom_type_name} || "${source}_$column"
115             ),
116             values => { map { _trim_name($_) => { value => $_ } } @{ $extra->{list} } },
117             }
118             },
119             );
120             my %TYPE2SCALAR = map { ($_ => 1) } qw(ID String Int Float Boolean);
121              
122             sub _dbicsource2pretty {
123 83     83   183 my ($source) = @_;
124 83 50       189 confess "_dbicsource2pretty given undef" if !defined $source;
125 83   66     143 $source = eval { $source->source_name } || $source;
126 83         6399 $source =~ s#.*::##;
127 83         303 $source = to_S $source;
128 83         77357 join '', map ucfirst, split /_+/, $source;
129             }
130              
131             sub _trim_name {
132 38     38   102 my ($name) = @_;
133 38 50       83 return if !defined $name;
134 38         112 $name =~ s#[^a-zA-Z0-9_]+#_#g;
135 38         176 $name;
136             }
137              
138             sub _apply_modifier {
139 854     854   1530 my ($modifier, $typespec) = @_;
140 854 100       1566 return $typespec if !$modifier;
141 741 100 100     2328 return $typespec if $modifier eq 'non_null'
      100        
142             and ref $typespec eq 'ARRAY'
143             and $typespec->[0] eq 'non_null'; # no double-non_null
144 678         2237 [ $modifier, { type => $typespec } ];
145             }
146              
147             sub _remove_modifiers {
148 68     68   124 my ($typespec) = @_;
149 68 100       197 return _remove_modifiers($typespec->{type}) if ref $typespec eq 'HASH';
150 50 100       162 return $typespec if ref $typespec ne 'ARRAY';
151 18         44 _remove_modifiers($typespec->[1]);
152             }
153              
154             sub _type2createinput {
155 28     28   93 my ($name, $fields, $pk21, $fk21, $column21, $name2type) = @_;
156             +{
157             kind => 'input',
158             name => "${name}CreateInput",
159             fields => {
160 162         309 (map { ($_ => $fields->{$_}) }
161 28   100     343 grep !$pk21->{$_} && !$fk21->{$_}, keys %$column21),
162             _make_fk_fields($name, $fk21, $name2type),
163             },
164             };
165             }
166              
167             sub _type2idinput {
168 28     28   67 my ($name, $fields, $pk21) = @_;
169             +{
170             kind => 'input',
171             name => "${name}IDInput",
172             fields => {
173 28         98 (map { ($_ => $fields->{$_}) }
  27         133  
174             keys %$pk21),
175             },
176             };
177             }
178              
179             sub _type2searchinput {
180 30     30   71 my ($name, $column2rawtype, $pk21, $column21) = @_;
181             +{
182             kind => 'input',
183             name => "${name}SearchInput",
184             fields => {
185 171         536 (map { ($_ => { type => $column2rawtype->{$_} }) }
186 30         149 grep !$pk21->{$_}, keys %$column21),
187             },
188             };
189             }
190              
191             sub _type2updateinput {
192 28     28   62 my ($name) = @_;
193             +{
194 28         89 kind => 'input',
195             name => "${name}UpdateInput",
196             fields => {
197             id => { type => _apply_modifier('non_null', "${name}IDInput") },
198             payload => { type => _apply_modifier('non_null', "${name}SearchInput") },
199             },
200             };
201             }
202              
203             sub _make_fk_fields {
204 28     28   62 my ($name, $fk21, $name2type) = @_;
205 28         56 my $type = $name2type->{$name};
206             (map {
207 28         138 my $field_type = $type->{fields}{$_}{type};
  17         45  
208 17 100       40 if (!$TYPE2SCALAR{_remove_modifiers($field_type)}) {
209 15   66     53 my $non_null =
210             ref($field_type) eq 'ARRAY' && $field_type->[0] eq 'non_null';
211 15   100     58 $field_type = _apply_modifier(
212             $non_null && 'non_null', _remove_modifiers($field_type)."IDInput"
213             );
214             }
215 17         196 ($_ => { type => $field_type })
216             } keys %$fk21);
217             }
218              
219             sub field_resolver {
220 64     64 1 1186546 my ($root_value, $args, $context, $info) = @_;
221 64         164 my $field_name = $info->{field_name};
222 64         110 DEBUG and _debug('DBIC.resolver', $field_name, $args, $info);
223 64         192 my $parent_name = $info->{parent_type}->name;
224 64 100       254 if ($parent_name eq 'Mutation') {
    100          
225 3         18 goto &_mutation_resolver;
226             } elsif ($parent_name eq 'Query') {
227 8         48 goto &_query_resolver;
228             }
229             my $property = ref($root_value) eq 'HASH'
230 53 100       173 ? $root_value->{$field_name}
231             : $root_value;
232 53 50       160 return $property->($args, $context, $info) if ref $property eq 'CODE';
233 53 100 50     292 return $property // die "DBIC.resolver could not resolve '$field_name'\n"
      66        
234             if ref $root_value eq 'HASH' or !$root_value->can($field_name);
235 8 50       35 return $root_value->$field_name($args, $context, $info)
236             if !UNIVERSAL::isa($root_value, 'DBIx::Class::Core');
237             # dbic search
238 8         221 my $rs = $root_value->$field_name;
239 8 50       5847 $rs = [ $rs->all ] if $info->{return_type}->isa('GraphQL::Type::List');
240 8         33 return $rs;
241             }
242              
243             sub _subfieldrels {
244 13     13   32 my ($field_node) = @_;
245 13 50       49 die "_subfieldrels called on non-field" if $field_node->{kind} ne 'field';
246 13 50       26 return {} unless my @sels = @{ $field_node->{selections} || [] };
  13 50       67  
247 13 100       32 return {} unless my @withsels = grep @{ $_->{selections} || [] }, @sels;
  27 100       163  
248 4         9 +{ map { $_->{name} => _subfieldrels($_) } @withsels };
  5         21  
249             }
250              
251             sub _query_resolver {
252 8     8   30 my ($dbic_schema, $args, $context, $info) = @_;
253 8         189 my $name = $info->{return_type}->name;
254 8 100       279 my $method = $info->{return_type}->isa('GraphQL::Type::List')
255             ? 'search' : 'find';
256 8         19 my @subfieldrels = map _subfieldrels($_), @{$info->{field_nodes}};
  8         42  
257 8 100       38 $args = $args->{input} if ref $args->{input} eq 'HASH';
258 8         33 $args = +{ map { ("me.$_" => $args->{$_}) } keys %$args };
  8         44  
259 8         20 DEBUG and _debug('DBIC.root_value', $name, $method, $args, \@subfieldrels, $info);
260 8         84 my $rs = $dbic_schema->resultset($name);
261 8         5182 my $result = $rs->$method(
262             $args,
263             {
264             prefetch => { map %$_, @subfieldrels },
265             result_class => 'DBIx::Class::ResultClass::HashRefInflator'
266             },
267             );
268 8 100       8059 $result = [ $result->all ] if $method eq 'search';
269 8         133600 $result;
270             }
271              
272             sub _make_query_pk_field {
273 52     52   118 my ($typename, $type, $name2pk21, $is_list) = @_;
274 52         90 my $return_type = $typename;
275 52 100       154 $return_type = _apply_modifier('list', $return_type) if $is_list;
276             +{
277             type => $return_type,
278             args => {
279             map {
280 54         126 my $field_type = _apply_modifier('non_null', $type->{fields}{$_}{type});
281 54 100       145 $field_type = _apply_modifier('non_null', _apply_modifier('list',
282             $field_type
283             )) if $is_list;
284 54         326 $_ => { type => $field_type }
285 52         82 } keys %{ $name2pk21->{$typename} }
  52         127  
286             },
287             };
288             }
289              
290             sub _make_input_field {
291 114     114   283 my ($typename, $return_type, $mutation_kind, $list_in, $list_out) = @_;
292 114 50       282 $return_type = _apply_modifier('list', $return_type) if $list_out;
293 114         302 my $input_type = $typename . ucfirst($mutation_kind) . 'Input';
294 114         198 $input_type = _apply_modifier('non_null', $input_type);
295 114 100       277 $input_type = _apply_modifier('non_null', _apply_modifier('list',
296             $input_type
297             )) if $list_in;
298             +{
299 114         747 type => $return_type,
300             args => { input => { type => $input_type } },
301             };
302             }
303              
304             use constant MUTATE_ARGSPROCESS => {
305 2         20 update => sub { $_[0]->{payload} },
306             delete => sub { },
307 5     5   43 };
  5         35  
  5         788  
308             use constant MUTATE_POSTPROCESS => {
309 2 50       32 update => sub { ref($_[0]) eq 'GraphQL::Error' ? $_[0] : $_[0]->discard_changes },
310 1 50 50     13 delete => sub { ref($_[0]) eq 'GraphQL::Error' ? $_[0] : $_[0] && 1 },
311 5     5   38 };
  5         14  
  5         6919  
312             sub _mutation_resolver {
313 3     3   14 my ($dbic_schema, $args, $context, $info) = @_;
314 3         10 my $name = $info->{field_name};
315 3 50       27 die "Couldn't understand field '$name'"
316             unless $name =~ s/^(create|update|delete)//;
317 3         12 my $method = $1;
318 3         7 my $find_first = $method ne 'create';
319 3         19 my ($args_process, $result_process) = map $_->{$method},
320             MUTATE_ARGSPROCESS, MUTATE_POSTPROCESS;
321 3 50       16 $args = $args->{input} if $args->{input};
322 3         12 my $is_list = ref $args eq 'ARRAY';
323 3 50       14 $args = [ $args ] if !$is_list; # so can just deal as list below
324 3         5 DEBUG and _debug("DBIC.root_value", $args);
325 3         21 my $rs = $dbic_schema->resultset($name);
326             my $all_result = [
327             map {
328 3         1252 my $operand = $rs;
  5         14  
329 5 100       39 $operand = $operand->find($_->{id}) if $find_first;
330 5 100       16348 my $result = $operand
    100          
331             ? $operand->$method($args_process ? $args_process->($_) : $_)
332             : GraphQL::Error->coerce("$name not found");
333 5 100 100     63985 $result = $result_process->($result)
334             if $result_process and ref($result) ne 'GraphQL::Error';
335 5         10577 $result;
336             } @$args
337             ];
338 3 50       36 $all_result = $all_result->[0] if !$is_list;
339 3         28 $all_result
340             }
341              
342             sub to_graphql {
343 6     6 0 251620 my ($class, $dbic_schema) = @_;
344 6 50 50     53 $dbic_schema = $dbic_schema->() if ((ref($dbic_schema)||'') eq 'CODE');
345 6         14 my @ast;
346             my (
347 6         19 %name2type, %name2column21, %name2pk21, %name2fk21,
348             %name2column2rawtype, %seentype, %name2isview,
349             );
350 6         34 for my $source (map $dbic_schema->source($_), $dbic_schema->sources) {
351 30         1327 my $name = _dbicsource2pretty($source);
352 30         69 DEBUG and _debug("schema_dbic2graphql($name)", $source);
353 30 100       247 $name2isview{$name} = 1 if $source->can('view_definition');
354 30         62 my %fields;
355 30         131 my $columns_info = $source->columns_info;
356 30         1192 $name2pk21{$name} = +{ map { ($_ => 1) } $source->primary_columns };
  33         291  
357             my %rel2info = map {
358 30         346 ($_ => $source->relationship_info($_))
  40         272  
359             } $source->relationships;
360 30         476 for my $column (keys %$columns_info) {
361 216         342 my $info = $columns_info->{$column};
362 216         293 DEBUG and _debug("schema_dbic2graphql($name.col)", $column, $info);
363 216         471 my $rawtype = $TYPEMAP{ lc $info->{data_type} };
364 216 100       426 if ( 'CODE' eq ref $rawtype ) {
365 13         36 my $col_spec = $rawtype->($name, $column, $info);
366 13 100       54 push @ast, $col_spec unless $seentype{$col_spec->{name}};
367 13         27 $rawtype = $col_spec->{name};
368 13         50 $seentype{$col_spec->{name}} = 1;
369             }
370 216         427 $name2column2rawtype{$name}->{$column} = $rawtype;
371             my $fulltype = _apply_modifier(
372 216   100     797 !$info->{is_nullable} && 'non_null',
      50        
373             $rawtype
374 0         0 // die "'$column' unknown data type: @{[lc $info->{data_type}]}\n",
375             );
376 216         529 $fields{$column} = +{ type => $fulltype };
377 216 100       476 $name2fk21{$name}->{$column} = 1 if $info->{is_foreign_key};
378 216         478 $name2column21{$name}->{$column} = 1;
379             }
380 30         100 for my $rel (keys %rel2info) {
381 40         77 my $info = $rel2info{$rel};
382 40         59 DEBUG and _debug("schema_dbic2graphql($name.rel)", $rel, $info);
383 40         110 my $type = _dbicsource2pretty($info->{source});
384 40         128 $rel =~ s/_id$//; # dumb heuristic
385 40         97 delete $name2column21{$name}->{$rel}; # so it's not a "column" now
386 40         81 delete $name2pk21{$name}{$rel}; # it's not a PK either
387             # if it WAS a column, capture its non-null-ness
388 40   100     194 my $non_null = ref(($fields{$rel} || {})->{type}) eq 'ARRAY';
389 40 100       113 $type = _apply_modifier('non_null', $type) if $non_null;
390 40 100       174 $type = _apply_modifier('list', $type) if $info->{attrs}{accessor} eq 'multi';
391 40 100       98 $type = _apply_modifier('non_null', $type) if $non_null; # in case list
392 40         160 $fields{$rel} = +{ type => $type };
393             }
394 30         124 my $spec = +{
395             kind => 'type',
396             name => $name,
397             fields => \%fields,
398             };
399 30         72 $name2type{$name} = $spec;
400 30         136 push @ast, $spec;
401             }
402             push @ast, map _type2idinput(
403             $_, $name2type{$_}->{fields}, $name2pk21{$_},
404             $name2column21{$_},
405 6   66     85 ), grep !$name2isview{$_} || keys %{ $name2pk21{$_} }, keys %name2type;
406             push @ast, map _type2createinput(
407             $_, $name2type{$_}->{fields}, $name2pk21{$_}, $name2fk21{$_},
408             $name2column21{$_}, \%name2type,
409 6         60 ), grep !$name2isview{$_}, keys %name2type;
410             push @ast, map _type2searchinput(
411             $_, $name2column2rawtype{$_}, $name2pk21{$_},
412 6         43 $name2column21{$_},
413             ), keys %name2type;
414 6         45 push @ast, map _type2updateinput($_), grep !$name2isview{$_}, keys %name2type;
415             push @ast, {
416             kind => 'type',
417             name => 'Query',
418             fields => {
419             map {
420 6         36 my $name = $_;
  30         62  
421 30         58 my $type = $name2type{$name};
422 30         70 my $pksearch_name = lcfirst $name;
423 30         103 my $pksearch_name_plural = to_PL($pksearch_name);
424 30         27915 my $input_search_name = "search$name";
425 30         101 my @fields = (
426             $input_search_name => _make_input_field($name, $name, 'search', 0, 1),
427             );
428             push @fields, map((
429             ($_ ? $pksearch_name_plural : $pksearch_name),
430             _make_query_pk_field($name, $type, \%name2pk21, $_),
431 30 100       66 ), (0, 1)) if keys %{ $name2pk21{$name} };
  30 100       183  
432 30         190 @fields;
433             } keys %name2type
434             },
435             };
436             push @ast, {
437             kind => 'type',
438             name => 'Mutation',
439             fields => {
440             map {
441 28         55 my $name = $_;
442 28         63 my $create_name = "create$name";
443 28         57 my $update_name = "update$name";
444 28         93 my $delete_name = "delete$name";
445             (
446 28         83 $create_name => _make_input_field($name, $name, 'create', 1, 1),
447             $update_name => _make_input_field($name, $name, 'update', 1, 1),
448             $delete_name => _make_input_field($name, 'Boolean', 'ID', 1, 1),
449             )
450 6         52 } grep !$name2isview{$_}, keys %name2type
451             },
452             };
453             +{
454 6         136 schema => GraphQL::Schema->from_ast(\@ast),
455             root_value => $dbic_schema,
456             resolver => \&field_resolver,
457             };
458             }
459              
460             =encoding utf-8
461              
462             =head1 NAME
463              
464             GraphQL::Plugin::Convert::DBIC - convert DBIx::Class schema to GraphQL schema
465              
466             =begin markdown
467              
468             # PROJECT STATUS
469              
470             | OS | Build status |
471             |:-------:|--------------:|
472             | Linux | [![Build Status](https://travis-ci.org/graphql-perl/GraphQL-Plugin-Convert-DBIC.svg?branch=master)](https://travis-ci.org/graphql-perl/GraphQL-Plugin-Convert-DBIC) |
473              
474             [![CPAN version](https://badge.fury.io/pl/GraphQL-Plugin-Convert-DBIC.svg)](https://metacpan.org/pod/GraphQL::Plugin::Convert::DBIC)
475              
476             =end markdown
477              
478             =head1 SYNOPSIS
479              
480             use GraphQL::Plugin::Convert::DBIC;
481             use Schema;
482             my $converted = GraphQL::Plugin::Convert::DBIC->to_graphql(Schema->connect);
483             print $converted->{schema}->to_doc;
484              
485             =head1 DESCRIPTION
486              
487             This module implements the L<GraphQL::Plugin::Convert> API to convert
488             a L<DBIx::Class::Schema> to L<GraphQL::Schema> etc.
489              
490             Its C<Query> type represents a guess at what fields are suitable, based
491             on providing a lookup for each type (a L<DBIx::Class::ResultSource>).
492              
493             =head2 Example
494              
495             Consider this minimal data model:
496              
497             blog:
498             id # primary key
499             articles # has_many
500             title # non null
501             language # nullable
502             article:
503             id # primary key
504             blog # foreign key to Blog
505             title # non null
506             content # nullable
507              
508             =head2 Generated Output Types
509              
510             These L<GraphQL::Type::Object> types will be generated:
511              
512             type Blog {
513             id: Int!
514             articles: [Article]
515             title: String!
516             language: String
517             }
518              
519             type Article {
520             id: Int!
521             blog: Blog
522             title: String!
523             content: String
524             }
525              
526             type Query {
527             blog(id: [Int!]!): [Blog]
528             article(id: [Int!]!): [Blog]
529             }
530              
531             Note that while the queries take a list, the return order is
532             undefined. This also applies to the mutations. If this matters, request
533             the primary key fields and use those to sort.
534              
535             =head2 Generated Input Types
536              
537             Different input types are needed for each of CRUD (Create, Read, Update,
538             Delete).
539              
540             The create one needs to have non-null fields be non-null, for idiomatic
541             GraphQL-level error-catching. The read one needs all fields nullable,
542             since this will be how searches are implemented, allowing fields to be
543             left un-searched-for. Both need to omit primary key fields. The read
544             one also needs to omit foreign key fields, since the idiomatic GraphQL
545             way for this is to request the other object, with this as a field on it,
546             then request any required fields of this.
547              
548             Meanwhile, the update and delete ones need to include the primary key
549             fields, to indicate what to mutate, and also all non-primary key fields
550             as nullable, which for update will mean leaving them unchanged, and for
551             delete is to be ignored. These input types are split into one input
552             for the primary keys, which is a full input type to allow for multiple
553             primary keys, then a wrapper input for updates, that takes one ID input,
554             and a payload that due to the same requirements, is just the search input.
555              
556             Therefore, for the above, these input types (and an updated Query,
557             and Mutation) are created:
558              
559             input BlogCreateInput {
560             title: String!
561             language: String
562             }
563              
564             input BlogSearchInput {
565             title: String
566             language: String
567             }
568              
569             input BlogIDInput {
570             id: Int!
571             }
572              
573             input BlogUpdateInput {
574             id: BlogIDInput!
575             payload: BlogSearchInput!
576             }
577              
578             input ArticleCreateInput {
579             blog_id: Int!
580             title: String!
581             content: String
582             }
583              
584             input ArticleSearchInput {
585             title: String
586             content: String
587             }
588              
589             input ArticleIDInput {
590             id: Int!
591             }
592              
593             input ArticleUpdateInput {
594             id: ArticleIDInput!
595             payload: ArticleSearchInput!
596             }
597              
598             type Mutation {
599             createBlog(input: [BlogCreateInput!]!): [Blog]
600             createArticle(input: [ArticleCreateInput!]!): [Article]
601             deleteBlog(input: [BlogIDInput!]!): [Boolean]
602             deleteArticle(input: [ArticleIDInput!]!): [Boolean]
603             updateBlog(input: [BlogUpdateInput!]!): [Blog]
604             updateArticle(input: [ArticleUpdateInput!]!): [Article]
605             }
606              
607             extends type Query {
608             searchBlog(input: BlogSearchInput!): [Blog]
609             searchArticle(input: ArticleSearchInput!): [Article]
610             }
611              
612             =head1 ARGUMENTS
613              
614             To the C<to_graphql> method: a L<DBIx::Class::Schema> object.
615              
616             =head1 PACKAGE FUNCTIONS
617              
618             =head2 field_resolver
619              
620             This is available as C<\&GraphQL::Plugin::Convert::DBIC::field_resolver>
621             in case it is wanted for use outside of the "bundle" of the C<to_graphql>
622             method.
623              
624             =head1 DEBUGGING
625              
626             To debug, set environment variable C<GRAPHQL_DEBUG> to a true value.
627              
628             =head1 AUTHOR
629              
630             Ed J, C<< <etj at cpan.org> >>
631              
632             =head1 LICENSE
633              
634             Copyright (C) Ed J
635              
636             This library is free software; you can redistribute it and/or modify
637             it under the same terms as Perl itself.
638              
639             =cut
640              
641             1;