|
0 |
package Database::DumpTruck;
|
|
1 |
|
|
2 |
=head1 NAME
|
|
3 |
|
|
4 |
Database::DumpTruck - Relaxing interface to SQLite
|
|
5 |
|
|
6 |
=head1 SYNOPSIS
|
|
7 |
|
|
8 |
my $dt = new Database::DumpTruck;
|
|
9 |
|
|
10 |
$dt->insert({Hello => 'World'});
|
|
11 |
$dt->create_index(['Hello']);
|
|
12 |
$dt->upsert({Hello => 'World', Yolo => 8086});
|
|
13 |
my $data = $dt->dump;
|
|
14 |
|
|
15 |
$dt->insert([
|
|
16 |
{Hello => 'World'},
|
|
17 |
{Hello => 'Hell', Structured => {
|
|
18 |
key => value,
|
|
19 |
array => [ 1, 2, 3, {} ],
|
|
20 |
}}], 'table2');
|
|
21 |
my $data2 = $dt->dump('table2');
|
|
22 |
$dt->drop('table2');
|
|
23 |
$dt->execute('SELECT 666');
|
|
24 |
|
|
25 |
my @columns = $dt->column_names();
|
|
26 |
|
|
27 |
$dt->save_var('number_of_the_beast', 666);
|
|
28 |
my $number_of_the_beast = $dt->get_var('number_of_the_beast');
|
|
29 |
|
|
30 |
=head1 DESCRIPTION
|
|
31 |
|
|
32 |
This is a simple document-oriented interface to a SQLite database, modelled
|
|
33 |
after Scraperwiki's Python C<dumptruck> module. It allows for easy (and maybe
|
|
34 |
inefficient) storage and retrieval of structured data to and from a database
|
|
35 |
without interfacing with SQL.
|
|
36 |
|
|
37 |
L<Database::DumpTruck> attempts to identify the type of the data you're
|
|
38 |
inserting and uses an appropriate SQLite type:
|
|
39 |
|
|
40 |
=over 4
|
|
41 |
|
|
42 |
=item C<integer>
|
|
43 |
|
|
44 |
This is used for integer values. Will be used for C<8086>, but not C<"8086"> or
|
|
45 |
C<8086.0>.
|
|
46 |
|
|
47 |
=item C<real>
|
|
48 |
|
|
49 |
This is used for numeric values that are not integer. Will be used for
|
|
50 |
C<8086.0>, but not C<"8086"> or C<8086>.
|
|
51 |
|
|
52 |
=item C<bool>
|
|
53 |
|
|
54 |
This is used for values that look like result of logical statemen. A crude
|
|
55 |
check for values that are both C<""> and C<0> or both C<"1"> and C<1> at the
|
|
56 |
same time is in place. This is a result of comparison or a negation.
|
|
57 |
|
|
58 |
To force a value to look like boolean, prepend it with a double negation: e.g.
|
|
59 |
C<!!0> or C<!!1>.
|
|
60 |
|
|
61 |
=item C<json text>
|
|
62 |
|
|
63 |
Used for C<ARRAY> and C<HASH> references. Values are converted into and from
|
|
64 |
JSON strings upon C<insert> and C<dump>.
|
|
65 |
|
|
66 |
=item C<text>
|
|
67 |
|
|
68 |
Pretty much everything else.
|
|
69 |
|
|
70 |
=back
|
|
71 |
|
|
72 |
=cut
|
|
73 |
|
|
74 |
use strict;
|
|
75 |
use warnings;
|
|
76 |
|
|
77 |
use DBI;
|
|
78 |
use B;
|
|
79 |
use JSON;
|
|
80 |
require DBD::SQLite;
|
|
81 |
|
|
82 |
our $VERSION = '1.1';
|
|
83 |
|
|
84 |
sub get_column_type
|
|
85 |
{
|
|
86 |
my $v = shift;
|
|
87 |
|
|
88 |
return '' unless defined $v;
|
|
89 |
|
|
90 |
# A reference?
|
|
91 |
my $ref = ref $v;
|
|
92 |
if ($ref) {
|
|
93 |
return 'json text' if $ref eq 'ARRAY' or $ref eq 'HASH';
|
|
94 |
# TODO: blessings into some magic package names to force a type?
|
|
95 |
# TODO: What's the most canonical package to describe datetime?
|
|
96 |
}
|
|
97 |
|
|
98 |
# A scalar.
|
|
99 |
my $obj = B::svref_2object (\$v);
|
|
100 |
my $flags = $obj->FLAGS;
|
|
101 |
|
|
102 |
# Could here be a better way to detect a boolean?
|
|
103 |
if (($flags & (B::SVf_NOK | B::SVf_POK))
|
|
104 |
== (B::SVf_NOK | B::SVf_POK))
|
|
105 |
{
|
|
106 |
return 'bool'
|
|
107 |
if ($obj->NV == 0 && $obj->PV eq '')
|
|
108 |
or ($obj->NV == 1 && $obj->PV eq '1');
|
|
109 |
}
|
|
110 |
|
|
111 |
return 'text' if $flags & B::SVf_POK;
|
|
112 |
return 'real' if $flags & B::SVf_NOK;
|
|
113 |
return 'integer' if $flags & B::SVf_IOK;
|
|
114 |
|
|
115 |
return 'text';
|
|
116 |
}
|
|
117 |
|
|
118 |
sub convert
|
|
119 |
{
|
|
120 |
my $data = shift;
|
|
121 |
my @retval;
|
|
122 |
|
|
123 |
foreach my $row (ref $data eq 'ARRAY' ? @$data : ($data)) {
|
|
124 |
push @retval, [ map { [ $_ => $row->{$_} ] } sort keys %$row ];
|
|
125 |
}
|
|
126 |
|
|
127 |
return \@retval;
|
|
128 |
}
|
|
129 |
|
|
130 |
sub simplify
|
|
131 |
{
|
|
132 |
my $text = shift;
|
|
133 |
$text =~ s/[^a-zA-Z0-9]//g;
|
|
134 |
return $text;
|
|
135 |
}
|
|
136 |
|
|
137 |
=head1 METHODS
|
|
138 |
|
|
139 |
=over 4
|
|
140 |
|
|
141 |
=item B<new> ([params])
|
|
142 |
|
|
143 |
Initialize the database handle. Accepts optional hash with parameters:
|
|
144 |
|
|
145 |
=over 8
|
|
146 |
|
|
147 |
=item B<dbname> (Default: C<dumptruck.db>)
|
|
148 |
|
|
149 |
The database file.
|
|
150 |
|
|
151 |
=item B<table> (Default: C<dumptruck>)
|
|
152 |
|
|
153 |
Name for the default table.
|
|
154 |
|
|
155 |
=item B<vars_table> (Default: C<_dumptruckvars>)
|
|
156 |
|
|
157 |
Name of the variables table.
|
|
158 |
|
|
159 |
=item B<vars_table_tmp> (Default: C<_dumptruckvarstmp>)
|
|
160 |
|
|
161 |
Name of the temporary table used when converting the values for variables table.
|
|
162 |
|
|
163 |
=item B<auto_commit> (Default: C<1>)
|
|
164 |
|
|
165 |
Enable automatic commit.
|
|
166 |
|
|
167 |
=back
|
|
168 |
|
|
169 |
=cut
|
|
170 |
|
|
171 |
sub new
|
|
172 |
{
|
|
173 |
my $class = shift;
|
|
174 |
my $self = shift || {};
|
|
175 |
|
|
176 |
$self->{dbname} ||= 'dumptruck.db';
|
|
177 |
$self->{table} ||= 'dumptruck';
|
|
178 |
$self->{vars_table} ||= '_dumptruckvars';
|
|
179 |
$self->{vars_table_tmp} ||= '_dumptruckvarstmp';
|
|
180 |
$self->{auto_commit} = 1
|
|
181 |
unless exists $self->{auto_commit};
|
|
182 |
|
|
183 |
$self->{dbh} = DBI->connect("dbi:SQLite:$self->{dbname}","","", {
|
|
184 |
AutoCommit => $self->{auto_commit},
|
|
185 |
RaiseError => 1, PrintError => 0 })
|
|
186 |
or die "Could get a database handle: $!";
|
|
187 |
$self->{dbh}{sqlite_unicode} = 1;
|
|
188 |
|
|
189 |
return bless $self, $class;
|
|
190 |
}
|
|
191 |
|
|
192 |
=item B<column_names> ([table_name])
|
|
193 |
|
|
194 |
Return a list of names of all columns in given table, or table C<dumptruck>.
|
|
195 |
|
|
196 |
=cut
|
|
197 |
|
|
198 |
sub column_names
|
|
199 |
{
|
|
200 |
my $self = shift;
|
|
201 |
my $table_name = shift || $self->{table};
|
|
202 |
|
|
203 |
$self->execute (sprintf 'PRAGMA table_info(%s)',
|
|
204 |
$self->{dbh}->quote ($table_name))
|
|
205 |
}
|
|
206 |
|
|
207 |
sub _check_or_create_vars_table
|
|
208 |
{
|
|
209 |
my $self = shift;
|
|
210 |
|
|
211 |
$self->execute (sprintf 'CREATE TABLE IF NOT EXISTS %s '.
|
|
212 |
'(`key` text PRIMARY KEY, `value` blob, `type` text)',
|
|
213 |
$self->{dbh}->quote ($self->{vars_table}));
|
|
214 |
}
|
|
215 |
|
|
216 |
=item B<execute> (sql, [params])
|
|
217 |
|
|
218 |
Run a raw SQL statement and get structured output. Optional parameters for C<?>
|
|
219 |
placeholders can be specified.
|
|
220 |
|
|
221 |
=cut
|
|
222 |
|
|
223 |
sub execute
|
|
224 |
{
|
|
225 |
my $self = shift;
|
|
226 |
my $sql = shift;
|
|
227 |
my @params = @_;
|
|
228 |
my @retval;
|
|
229 |
|
|
230 |
warn "Executing statement: '$sql'" if $self->{debug};
|
|
231 |
my $sth = $self->{dbh}->prepare ($sql);
|
|
232 |
$sth->execute (@params);
|
|
233 |
|
|
234 |
return [] unless $sth->{NUM_OF_FIELDS};
|
|
235 |
|
|
236 |
while (my $row = $sth->fetch) {
|
|
237 |
my $types = $sth->{TYPE};
|
|
238 |
my $names = $sth->{NAME_lc};
|
|
239 |
push @retval, {};
|
|
240 |
|
|
241 |
foreach (0..$#$row) {
|
|
242 |
my $data = $row->[$_];
|
|
243 |
$data = decode_json ($data) if $data and $types->[$_] eq 'json text';
|
|
244 |
$retval[$#retval]->{$names->[$_]} = $data;
|
|
245 |
}
|
|
246 |
};
|
|
247 |
|
|
248 |
return \@retval;
|
|
249 |
}
|
|
250 |
|
|
251 |
=item B<commit> ()
|
|
252 |
|
|
253 |
Commit outstanding transaction. Useful when C<auto_commit> is off.
|
|
254 |
|
|
255 |
=cut
|
|
256 |
|
|
257 |
sub commit
|
|
258 |
{
|
|
259 |
my $self = shift;
|
|
260 |
|
|
261 |
$self->{dbh}->commit;
|
|
262 |
}
|
|
263 |
|
|
264 |
=item B<close> ()
|
|
265 |
|
|
266 |
Close the database handle. You should not need to call this explicitly.
|
|
267 |
|
|
268 |
=cut
|
|
269 |
|
|
270 |
sub close
|
|
271 |
{
|
|
272 |
my $self = shift;
|
|
273 |
|
|
274 |
$self->{dbh}->disconnect;
|
|
275 |
$self->{dbh} = undef;
|
|
276 |
}
|
|
277 |
|
|
278 |
=item B<create_index> (columns, [table_name], [if_not_exists], [unique])
|
|
279 |
|
|
280 |
Create an optionally unique index on columns in a given table. Can be told
|
|
281 |
to do nothing if the index already exists.
|
|
282 |
|
|
283 |
=cut
|
|
284 |
|
|
285 |
sub create_index
|
|
286 |
{
|
|
287 |
my $self = shift;
|
|
288 |
my $columns = shift;
|
|
289 |
my $table_name = shift || $self->{table};
|
|
290 |
my $if_not_exists = shift;
|
|
291 |
$if_not_exists = (not defined $if_not_exists or $if_not_exists)
|
|
292 |
? 'IF NOT EXISTS' : '';
|
|
293 |
my $unique = (shift) ? 'UNIQUE' : '';
|
|
294 |
|
|
295 |
my $index_name = join '_', (simplify ($table_name),
|
|
296 |
map { simplify ($_) } @$columns);
|
|
297 |
|
|
298 |
$self->execute (sprintf 'CREATE %s INDEX %s %s ON %s (%s)',
|
|
299 |
$unique, $if_not_exists, $index_name,
|
|
300 |
$self->{dbh}->quote ($table_name),
|
|
301 |
join (',', map { $self->{dbh}->quote ($_) } @$columns));
|
|
302 |
}
|
|
303 |
|
|
304 |
sub _check_and_add_columns
|
|
305 |
{
|
|
306 |
my $self = shift;
|
|
307 |
my $table_name = shift;
|
|
308 |
my $row = shift;
|
|
309 |
|
|
310 |
foreach (@$row) {
|
|
311 |
my ($k, $v) = @$_;
|
|
312 |
eval { $self->execute (sprintf 'ALTER TABLE %s ADD COLUMN %s %s',
|
|
313 |
$self->{dbh}->quote ($table_name),
|
|
314 |
$self->{dbh}->quote ($k), get_column_type ($v)) };
|
|
315 |
die if $@ and not $@ =~ /duplicate column name/;
|
|
316 |
}
|
|
317 |
}
|
|
318 |
|
|
319 |
=item B<create_table> (data, table_name, [error_if_exists])
|
|
320 |
|
|
321 |
Create a table and optionally error out if it already exists. The data
|
|
322 |
structure will be based on data, though no data will be inserted.
|
|
323 |
|
|
324 |
=cut
|
|
325 |
|
|
326 |
sub create_table
|
|
327 |
{
|
|
328 |
my $self = shift;
|
|
329 |
my $data = shift;
|
|
330 |
my $table_name = shift or die 'Need table name';
|
|
331 |
my $error_if_exists = shift;
|
|
332 |
|
|
333 |
# Get ordered key-value pairs
|
|
334 |
my $converted_data = convert ($data);
|
|
335 |
die 'No data passed' unless $converted_data->[0];
|
|
336 |
|
|
337 |
# Find first non-null column
|
|
338 |
my $startdata = $converted_data->[0];
|
|
339 |
my ($k, $v);
|
|
340 |
foreach (@$startdata) {
|
|
341 |
($k, $v) = @$_;
|
|
342 |
last if defined $v;
|
|
343 |
}
|
|
344 |
|
|
345 |
# No columns, don't attempt table creation. Do not die either as
|
|
346 |
# the table might already exist and user may just want to insert
|
|
347 |
# an all-default/empty row.
|
|
348 |
return unless $k;
|
|
349 |
|
|
350 |
# Create the table with the first column
|
|
351 |
my $if_not_exists = 'IF NOT EXISTS' unless $error_if_exists;
|
|
352 |
$self->execute (sprintf 'CREATE TABLE %s %s (%s %s)',
|
|
353 |
$if_not_exists, $self->{dbh}->quote ($table_name),
|
|
354 |
$self->{dbh}->quote ($k), get_column_type ($v));
|
|
355 |
|
|
356 |
# Add other rows
|
|
357 |
foreach (@$converted_data) {
|
|
358 |
$self->_check_and_add_columns ($table_name, $_);
|
|
359 |
}
|
|
360 |
}
|
|
361 |
|
|
362 |
=item B<insert> (data, [table_name], [upsert])
|
|
363 |
|
|
364 |
Insert (and optionally replace) data into a given table or C<dumptruck>.
|
|
365 |
Creates the table with proper structure if it does not exist already.
|
|
366 |
|
|
367 |
=cut
|
|
368 |
|
|
369 |
sub insert
|
|
370 |
{
|
|
371 |
my $self = shift;
|
|
372 |
my $data = shift;
|
|
373 |
my $table_name = shift || $self->{table};
|
|
374 |
my $upsert = shift;
|
|
375 |
|
|
376 |
# Override existing entries
|
|
377 |
my $upserttext = ($upsert ? 'OR REPLACE' : '');
|
|
378 |
|
|
379 |
# Ensure the table itself exists
|
|
380 |
$self->create_table ($data, $table_name);
|
|
381 |
|
|
382 |
# Learn about the types of already existing fields
|
|
383 |
my %column_types = map { lc($_->{name}) => $_->{type} }
|
|
384 |
@{$self->column_names ($table_name)};
|
|
385 |
|
|
386 |
# Get ordered key-value pairs
|
|
387 |
my $converted_data = convert ($data);
|
|
388 |
die 'No data passed' unless $converted_data and $converted_data->[0];
|
|
389 |
|
|
390 |
# Add other rows
|
|
391 |
my @rowids;
|
|
392 |
foreach (@$converted_data) {
|
|
393 |
$self->_check_and_add_columns ($table_name, $_);
|
|
394 |
|
|
395 |
my (@keys, @values);
|
|
396 |
foreach my $cols (@$_) {
|
|
397 |
my ($key, $value) = @$cols;
|
|
398 |
|
|
399 |
# Learn about the type and possibly do a conversion
|
|
400 |
my $type = $column_types{lc($key)} or get_column_type ($value);
|
|
401 |
$value = encode_json ($value) if $type eq 'json text';
|
|
402 |
|
|
403 |
push @keys, $key;
|
|
404 |
push @values, $value;
|
|
405 |
}
|
|
406 |
|
|
407 |
if (@keys) {
|
|
408 |
my $question_marks = join ',', map { '?' } 1..@keys;
|
|
409 |
$self->execute (sprintf ('INSERT %s INTO %s (%s) VALUES (%s)',
|
|
410 |
$upserttext, $self->{dbh}->quote ($table_name),
|
|
411 |
join (',', map { $self->{dbh}->quote($_) } @keys),
|
|
412 |
$question_marks), @values);
|
|
413 |
} else {
|
|
414 |
$self->execute (sprintf 'INSERT %s INTO %s DEFAULT VALUES',
|
|
415 |
$upserttext, $self->{dbh}->quote ($table_name));
|
|
416 |
}
|
|
417 |
|
|
418 |
push @rowids, $self->execute ('SELECT last_insert_rowid()')
|
|
419 |
->[0]{'last_insert_rowid()'};
|
|
420 |
}
|
|
421 |
return (ref $data eq 'HASH' and $data->{keys}) ? $rowids[0] : @rowids;
|
|
422 |
}
|
|
423 |
|
|
424 |
=item B<upsert> (data, [table_name])
|
|
425 |
|
|
426 |
Replace data into a given table or C<dumptruck>. Creates the table with proper
|
|
427 |
structure if it does not exist already.
|
|
428 |
|
|
429 |
Equivalent to calling C<insert> with C<upsert> parameter set to C<1>.
|
|
430 |
|
|
431 |
=cut
|
|
432 |
|
|
433 |
sub upsert
|
|
434 |
{
|
|
435 |
my $self = shift;
|
|
436 |
my $data = shift;
|
|
437 |
my $table_name = shift;
|
|
438 |
|
|
439 |
$self->insert ($data, $table_name, 1);
|
|
440 |
}
|
|
441 |
|
|
442 |
=item B<get_var> (key)
|
|
443 |
|
|
444 |
Retrieve a saved value for given key from the variable database.
|
|
445 |
|
|
446 |
=cut
|
|
447 |
|
|
448 |
sub get_var
|
|
449 |
{
|
|
450 |
my $self = shift;
|
|
451 |
my $k = shift;
|
|
452 |
|
|
453 |
my $data = $self->execute(sprintf ('SELECT * FROM %s WHERE `key` = ?',
|
|
454 |
$self->{dbh}->quote ($self->{vars_table})), $k);
|
|
455 |
return unless defined $data and exists $data->[0];
|
|
456 |
|
|
457 |
# Create a temporary table, to take advantage of the type
|
|
458 |
# guessing and conversion we do in dump()
|
|
459 |
$self->execute (sprintf 'CREATE TEMPORARY TABLE %s (`value` %s)',
|
|
460 |
$self->{dbh}->quote ($self->{vars_table_tmp}),
|
|
461 |
$self->{dbh}->quote ($data->[0]{type}));
|
|
462 |
$self->execute (sprintf ('INSERT INTO %s (`value`) VALUES (?)',
|
|
463 |
$self->{dbh}->quote ($self->{vars_table_tmp})),
|
|
464 |
$data->[0]{value});
|
|
465 |
my $v = $self->dump ($self->{vars_table_tmp})->[0]{value};
|
|
466 |
$self->drop ($self->{vars_table_tmp});
|
|
467 |
|
|
468 |
return $v;
|
|
469 |
}
|
|
470 |
|
|
471 |
=item B<save_var> (key, value)
|
|
472 |
|
|
473 |
Insert a value for given key into the variable database.
|
|
474 |
|
|
475 |
=cut
|
|
476 |
|
|
477 |
sub save_var
|
|
478 |
{
|
|
479 |
my $self = shift;
|
|
480 |
my $k = shift;
|
|
481 |
my $v = shift;
|
|
482 |
|
|
483 |
$self->_check_or_create_vars_table;
|
|
484 |
|
|
485 |
# Create a temporary table, to take advantage of the type
|
|
486 |
# guessing and conversion we do in insert()
|
|
487 |
my $column_type = get_column_type ($v);
|
|
488 |
$self->drop ($self->{vars_table_tmp}, 1);
|
|
489 |
$self->insert ({ value => $v }, $self->{vars_table_tmp});
|
|
490 |
|
|
491 |
$self->execute(sprintf ('INSERT OR REPLACE INTO %s '.
|
|
492 |
'(`key`, `type`, `value`)'.
|
|
493 |
'SELECT ? AS key, ? AS type, value FROM %s',
|
|
494 |
$self->{dbh}->quote ($self->{vars_table}),
|
|
495 |
$self->{dbh}->quote ($self->{vars_table_tmp})),
|
|
496 |
$k, get_column_type ($v));
|
|
497 |
|
|
498 |
$self->drop ($self->{vars_table_tmp});
|
|
499 |
}
|
|
500 |
|
|
501 |
=item B<tables> ()
|
|
502 |
|
|
503 |
Returns a list of names of all tables in the database.
|
|
504 |
|
|
505 |
=cut
|
|
506 |
|
|
507 |
sub tables
|
|
508 |
{
|
|
509 |
my $self = shift;
|
|
510 |
|
|
511 |
map { $_->{name} } @{$self->execute
|
|
512 |
('SELECT name FROM sqlite_master WHERE TYPE="table"')};
|
|
513 |
}
|
|
514 |
|
|
515 |
=item B<dump> ([table_name])
|
|
516 |
|
|
517 |
Returns all data from the given table or C<dumptduck> nicely structured.
|
|
518 |
|
|
519 |
=cut
|
|
520 |
|
|
521 |
sub dump
|
|
522 |
{
|
|
523 |
my $self = shift;
|
|
524 |
my $table_name = shift || $self->{table};
|
|
525 |
|
|
526 |
$self->execute (sprintf 'SELECT * FROM %s',
|
|
527 |
$self->{dbh}->quote ($table_name))
|
|
528 |
}
|
|
529 |
|
|
530 |
=item B<drop> ([table_name])
|
|
531 |
|
|
532 |
Drop the given table or C<dumptruck>.
|
|
533 |
|
|
534 |
=cut
|
|
535 |
|
|
536 |
sub drop
|
|
537 |
{
|
|
538 |
my $self = shift;
|
|
539 |
my $table_name = shift || $self->{table};
|
|
540 |
my $if_exists = shift;
|
|
541 |
|
|
542 |
$self->execute (sprintf 'DROP TABLE %s %s',
|
|
543 |
($if_exists ? 'IF EXISTS' : ''),
|
|
544 |
$self->{dbh}->quote ($table_name))
|
|
545 |
}
|
|
546 |
|
|
547 |
=back
|
|
548 |
|
|
549 |
=head1 BUGS
|
|
550 |
|
|
551 |
None known.
|
|
552 |
|
|
553 |
=head1 SEE ALSO
|
|
554 |
|
|
555 |
=over
|
|
556 |
|
|
557 |
=item *
|
|
558 |
|
|
559 |
L<https://github.com/scraperwiki/dumptruck> - Python module this one is
|
|
560 |
heavily inspired by.
|
|
561 |
|
|
562 |
=back
|
|
563 |
|
|
564 |
=head1 COPYRIGHT
|
|
565 |
|
|
566 |
This program is free software; you can redistribute it and/or modify it
|
|
567 |
under the same terms as Perl itself.
|
|
568 |
|
|
569 |
=head1 AUTHOR
|
|
570 |
|
|
571 |
Lubomir Rintel L<< <lkundrak@v3.sk> >>
|
|
572 |
|
|
573 |
=cut
|
|
574 |
|
|
575 |
1;
|