| File: | blib/lib/Genealogy/Obituary/Lookup.pm |
| Coverage: | 96.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Genealogy::Obituary::Lookup; | |||||
| 2 | ||||||
| 3 | 16 16 16 | 1426607 13 281 | use warnings; | |||
| 4 | 16 16 16 | 20 10 124 | use strict; | |||
| 5 | 16 16 16 | 1298 38062 52 | use autodie qw(:all); | |||
| 6 | 16 16 16 | 60087 18 850 | use feature 'state'; | |||
| 7 | ||||||
| 8 | 16 16 16 | 22 10 312 | use Carp; | |||
| 9 | 16 16 16 | 2854 39829 285 | use Data::Reuse; | |||
| 10 | 16 16 16 | 33 9 162 | use File::Spec; | |||
| 11 | 16 16 16 | 1606 31 68 | use Genealogy::Obituary::Lookup::obituaries; | |||
| 12 | 16 16 16 | 27145 25877 191 | use Module::Info; | |||
| 13 | 16 16 16 | 269 54409 140 | use Object::Configure 0.12; | |||
| 14 | 16 16 16 | 31 105 225 | use Params::Get 0.13; | |||
| 15 | 16 16 16 | 24 94 158 | use Params::Validate::Strict 0.09; | |||
| 16 | 16 16 16 | 23 8 240 | use Readonly; | |||
| 17 | 16 16 16 | 23 9 161 | use Return::Set; | |||
| 18 | 16 16 16 | 21 6 11771 | use Scalar::Util; | |||
| 19 | ||||||
| 20 | =encoding UTF-8 | |||||
| 21 | ||||||
| 22 - 30 | =head1 NAME Genealogy::Obituary::Lookup - Lookup an obituary in the ODT/Rootsweb/funeral-notices database =head1 VERSION Version 0.21 =cut | |||||
| 31 | ||||||
| 32 | our $VERSION = '0.21'; | |||||
| 33 | ||||||
| 34 | # --------------------------------------------------------------------------- | |||||
| 35 | # Constants | |||||
| 36 | # --------------------------------------------------------------------------- | |||||
| 37 | ||||||
| 38 | # URLs for the two archive sources; 'L' (local/link) has no fixed base URL. | |||||
| 39 | Readonly::Hash my %URLS => ( | |||||
| 40 | M => 'https://wayback.archive-it.org/20669/20231102044925/https://mlarchives.rootsweb.com/listindexes/emails?listname=gen-obit&page=', | |||||
| 41 | F => 'https://www.freelists.org/post/obitdailytimes/Obituary-Daily-Times-', | |||||
| 42 | ); | |||||
| 43 | ||||||
| 44 | Readonly::Scalar my $DEFAULT_CACHE_DURATION => '1 day'; # Database is rebuilt daily | |||||
| 45 | Readonly::Scalar my $MIN_LAST_NAME_LENGTH => 1; | |||||
| 46 | Readonly::Scalar my $MAX_LAST_NAME_LENGTH => 100; | |||||
| 47 | ||||||
| 48 | # --------------------------------------------------------------------------- | |||||
| 49 | # Internationalisation message map | |||||
| 50 | # --------------------------------------------------------------------------- | |||||
| 51 | # All user-facing error/warning text lives here. Keys are stable; templates | |||||
| 52 | # use %{name} placeholders that _i18n() fills with a hashref of named args. | |||||
| 53 | my %MESSAGES = ( | |||||
| 54 | err_no_self => "search() must be called on an object", | |||||
| 55 | err_no_args => 'Usage: %{package}->search(last => $val)', | |||||
| 56 | err_no_last => "Value for 'last' is mandatory", | |||||
| 57 | err_no_obituaries => "Can't open the obituaries database", | |||||
| 58 | err_no_page => '%{package}: undefined $page', | |||||
| 59 | err_no_source => '%{package}: %{page}: undefined source', | |||||
| 60 | err_bad_source => "%{package}: Invalid source, '%{source}'. Valid sources are 'M', 'F' and 'L'", | |||||
| 61 | err_no_newspaper => "%{package}: undefined newspaper. Newspaper must be given when source type is 'L'", | |||||
| 62 | err_bad_logger => "Logger must be an object with info(), warn() and error() methods", | |||||
| 63 | warn_not_dir => '%{class}: %{dir} is not a directory', | |||||
| 64 | warn_bad_usage => '%{package}: use ->new() not ::new() to instantiate', | |||||
| 65 | ); | |||||
| 66 | ||||||
| 67 - 239 | =head1 SYNOPSIS
use Genealogy::Obituary::Lookup;
# --- 1. Basic search: list context, all matching records ---
my $obits = Genealogy::Obituary::Lookup->new();
my @smiths = $obits->search(last => 'Smith');
foreach my $r (@smiths) {
printf "%s %s -- %s\n",
$r->{first} // '?', $r->{last}, $r->{url};
}
# --- 2. Scalar context: first matching record only ---
my $hit = $obits->search({ first => 'Eric', last => 'Baal' });
print $hit->{url}, "\n" if $hit;
# --- 3. Narrow a search with optional first, middle, and age ---
my @results = $obits->search(
first => 'Jean',
middle => 'Emily',
last => 'McCarthy',
);
# --- 4. Clone an object to use a different data directory ---
my $prod = Genealogy::Obituary::Lookup->new(directory => '/data/obits');
my $test = $prod->new(directory => 't/data'); # clone with override
my @test_hits = $test->search(last => 'Jones');
# --- 5. Attach a structured logger ---
use Log::Log4perl qw(:easy);
Log::Log4perl->easy_init($DEBUG);
my $logged = Genealogy::Obituary::Lookup->new(
logger => Log::Log4perl->get_logger(),
);
my @hits = $logged->search(last => 'Brown');
=head1 SUBROUTINES/METHODS
=head2 new
Creates a L<Genealogy::Obituary::Lookup> object.
my $obits = Genealogy::Obituary::Lookup->new();
my $clone = $obits->new(); # clone with no extra args
Accepts the following optional arguments:
=over 4
=item * C<cache> - passed to L<Database::Abstraction>
=item * C<config_file> - path to a YAML/XML/INI configuration file whose keys
are merged into the constructor arguments at runtime, allowing deployment-time
override without code changes.
=item * C<directory> - directory that contains F<obituaries.sql>. If a single
non-reference argument is passed to C<new()>, it is taken as C<directory>.
=item * C<logger> - object with C<info()>, C<warn()> and C<error()> methods (e.g.
L<Log::Log4perl>, L<Log::Any>). All three are required: C<warn()> is used for
non-fatal directory diagnostics; C<error()> for fatal DB errors.
=back
=head3 EXAMPLE
# Default: discovers data/ relative to the installed module file
my $default = Genealogy::Obituary::Lookup->new();
# Explicit directory (useful during development)
my $dev = Genealogy::Obituary::Lookup->new(directory => 't/data');
# With structured logging
use Log::Log4perl qw(:easy);
Log::Log4perl->easy_init($DEBUG);
my $logged = Genealogy::Obituary::Lookup->new(logger => Log::Log4perl->get_logger());
=head3 API SPECIFICATION
=head4 INPUT
{
'directory' => { type => 'string', optional => 1 },
'cache' => { type => 'any', optional => 1 },
'config_file' => { type => 'string', optional => 1 },
'logger' => {
type => 'object',
optional => 1,
can => [ 'info', 'error' ]
}
}
=head4 DOMAIN â directory
Valid partitions
EP-V Absent / undef Auto-discovers data/ relative to module file.
EP-V Existing readable dir Accepted; stored in $self->{directory}.
Invalid partitions (all carp + return undef)
EP-I Non-existent path Carps "not a directory".
EP-I Existing plain file Carps "not a directory".
EP-I Unreadable directory Carps "not a directory".
EP-I Empty string "" Carps "not a directory" (-d "" is false).
EP-I Path with null byte Rejected before -d (prevents "Embedded nulls" fatal).
=head4 DOMAIN â logger
Valid partition
EP-V Blessed object with can('info') && can('warn') && can('error') Accepted.
Additional methods beyond these three are fine.
Method roles
info() Informational messages (progress, cache hits). Non-fatal.
warn() Non-fatal diagnostics: bad directory, null byte in path.
Called instead of error() so that new() can carp+return undef
rather than die. Log::Abstraction::error() calls die(); using
it here would violate the graceful-return contract.
error() Fatal-severity events from search() when the DB cannot be opened.
Invalid partitions (all croak err_bad_logger)
EP-I String Not an object.
EP-I Number Not an object.
EP-I Unblessed hashref Not blessed.
EP-I Coderef Not blessed.
EP-I Object missing info() Incomplete interface.
EP-I Object missing warn() Incomplete interface.
EP-I Object missing error() Incomplete interface.
=head4 DOMAIN â invocation style
Valid
EP-V Pkg->new(...) Class method â normal invocation.
EP-V $obj->new(...) Object method â clone with optional overrides.
EP-V Pkg->new('/path') Single bare string â treated as directory.
EP-V Pkg->new({key=>val}) Hashref argument.
EP-V Pkg::new() No-arg bare call â tolerated silently.
Invalid
EP-I Pkg::new(undef, args) Croak warn_bad_usage (undef class + args detected).
=head4 OUTPUT
On success: blessed Genealogy::Obituary::Lookup hashref
On failure: undef (carp explains why)
=head3 MESSAGES
warn_not_dir - <class>: <dir> is not a directory.
Resolution: pass a valid, readable directory.
warn_bad_usage - use ->new() not ::new() when passing arguments.
Resolution: call as a class method.
err_bad_logger - Logger must have info(), warn() and error() methods.
Resolution: wrap your logger in an adapter.
=head3 PSEUDOCODE
1. Parse arguments: accept hashref, key=>value list, or single bare string
(treated as directory).
2. If called as a function (::new) with no args, tolerate and self-correct;
croak if args were given - the invocation is ambiguous.
3. If $class is already a blessed object, clone it: merge new args into a
copy of the existing hash and bless into the same class.
4. Validate the logger object if provided (must have info() and error()).
5. Merge config-file settings via Object::Configure.
6. Resolve the data directory: explicit arg > module-relative default.
7. For a plain-string directory: (a) reject null bytes immediately
(logger->warn + carp + return undef); (b) untaint via regex â the
capture is guaranteed to succeed because null bytes were just excluded.
8. Carp and call logger->warn if the directory is missing or unreadable;
return undef.
9. Bless and return with cache_duration defaulted (overridable by caller).
=cut | |||||
| 240 | ||||||
| 241 | sub new | |||||
| 242 | { | |||||
| 243 | 434 | 1458185 | my $class_in = shift; | |||
| 244 | 434 | 237 | my %args; | |||
| 245 | ||||||
| 246 | # Support: ->new('path'), ->new(key=>val), ->new({key=>val}) | |||||
| 247 | 434 | 1215 | if((scalar(@_) == 0) && !ref($class_in) && defined($class_in) && -d $class_in) { | |||
| 248 | # Called as Genealogy::Obituary::Lookup->new('/some/dir') | |||||
| 249 | # $class_in is the directory, not the class â handled below via scalar arg | |||||
| 250 | 1 | 2 | $args{'directory'} = $class_in; | |||
| 251 | 1 | 1 | $class_in = __PACKAGE__; | |||
| 252 | } elsif((scalar(@_) == 1) && !ref($_[0])) { | |||||
| 253 | 5 | 6 | $args{'directory'} = $_[0]; | |||
| 254 | } elsif(my $params = Params::Get::get_params(undef, @_)) { | |||||
| 255 | 396 396 | 3833 371 | %args = %{$params}; | |||
| 256 | } | |||||
| 257 | ||||||
| 258 | 434 | 777 | if(!defined($class_in)) { | |||
| 259 | # Called as Genealogy::Obituary::Lookup::new() â tolerate only if no args | |||||
| 260 | 11 | 12 | if(%args) { | |||
| 261 | 4 | 10 | Carp::croak(__PACKAGE__->_i18n('warn_bad_usage', {package => __PACKAGE__})); | |||
| 262 | } | |||||
| 263 | 7 | 8 | $class_in = __PACKAGE__; | |||
| 264 | } elsif(Scalar::Util::blessed($class_in)) { | |||||
| 265 | # Clone: merge new args over existing state and re-bless | |||||
| 266 | 24 24 | 15 58 | return bless { %{$class_in}, %args }, ref($class_in); | |||
| 267 | } | |||||
| 268 | ||||||
| 269 | # Validate the logger before Object::Configure can wrap it; the wrapper | |||||
| 270 | # always satisfies the interface check so we must test the original value. | |||||
| 271 | 406 | 318 | if(defined $args{'logger'}) { | |||
| 272 | 33 | 142 | unless(Scalar::Util::blessed($args{'logger'}) | |||
| 273 | && $args{'logger'}->can('info') | |||||
| 274 | && $args{'logger'}->can('warn') | |||||
| 275 | && $args{'logger'}->can('error')) | |||||
| 276 | { | |||||
| 277 | 23 | 42 | Carp::croak($class_in->_i18n('err_bad_logger')); | |||
| 278 | } | |||||
| 279 | } | |||||
| 280 | ||||||
| 281 | # Merge configuration file settings (YAML / XML / INI) into %args | |||||
| 282 | 383 383 | 168 450 | %args = %{Object::Configure::configure($class_in, \%args)}; | |||
| 283 | ||||||
| 284 | # Resolve the data directory, falling back to the module's own data/ subdirectory. | |||||
| 285 | # Cache the lookup per class: Module::Info scans %INC and stats the filesystem; | |||||
| 286 | # the result is stable for the process lifetime. | |||||
| 287 | 382 | 569206 | state %_dir_cache; | |||
| 288 | 382 | 436 | unless(defined $args{'directory'}) { | |||
| 289 | 19 | 29 | unless(exists $_dir_cache{$class_in}) { | |||
| 290 | 8 | 41 | my $info = Module::Info->new_from_loaded($class_in); | |||
| 291 | 8 | 471 | my $derived; | |||
| 292 | 8 | 20 | if(defined $info) { | |||
| 293 | 8 | 19 | (my $base = $info->file()) =~ s/\.pm\z//; | |||
| 294 | 8 | 51 | $derived = File::Spec->catfile($base, 'data'); | |||
| 295 | 8 | 47 | $derived = undef unless -d $derived; | |||
| 296 | } | |||||
| 297 | 8 | 34 | $_dir_cache{$class_in} = $derived; | |||
| 298 | } | |||||
| 299 | $args{'directory'} = $_dir_cache{$class_in} | |||||
| 300 | 19 | 114 | if defined $_dir_cache{$class_in}; | |||
| 301 | } | |||||
| 302 | ||||||
| 303 | # Premise: directory, if provided, must be a plain string (not a ref). | |||||
| 304 | # Merge the null-byte guard and untaint into one outer block â both share | |||||
| 305 | # the same defined+!ref precondition, eliminating a redundant test. | |||||
| 306 | 382 | 728 | if(defined($args{'directory'}) && !ref($args{'directory'})) { | |||
| 307 | # Null bytes cause a fatal "Embedded nulls" inside stat(). Reject first. | |||||
| 308 | 380 | 409 | if(index($args{'directory'}, "\0") >= 0) { | |||
| 309 | 6 | 13 | my $msg = $class_in->_i18n('warn_not_dir', | |||
| 310 | {class => $class_in, dir => '(path contains null byte)'}); | |||||
| 311 | 6 | 18 | $args{'logger'}->warn($msg) if $args{'logger'}; | |||
| 312 | 6 | 617 | Carp::carp($msg); | |||
| 313 | 6 | 1098 | return; | |||
| 314 | } | |||||
| 315 | # Conclusion: no null bytes remain, so m/\A([^\0]*)\z/ is guaranteed to | |||||
| 316 | # match â the capture is infallible. Untaint for taint-mode callers. | |||||
| 317 | 374 | 727 | ($args{'directory'}) = ($args{'directory'} =~ m/\A([^\0]*)\z/); | |||
| 318 | } | |||||
| 319 | ||||||
| 320 | 376 | 2858 | if(defined($args{'directory'}) && !((-d $args{'directory'}) && (-r $args{'directory'}))) { | |||
| 321 | my $msg = $class_in->_i18n('warn_not_dir', | |||||
| 322 | 34 | 80 | {class => $class_in, dir => $args{'directory'}}); | |||
| 323 | 34 | 95 | $args{'logger'}->warn($msg) if $args{'logger'}; | |||
| 324 | 34 | 3958 | Carp::carp($msg); | |||
| 325 | 34 | 6050 | return; | |||
| 326 | } | |||||
| 327 | ||||||
| 328 | 342 | 813 | return bless { cache_duration => $DEFAULT_CACHE_DURATION, %args }, $class_in; | |||
| 329 | } | |||||
| 330 | ||||||
| 331 - 520 | =head2 search
Searches the obituary database.
# List context: all matching records
my @smiths = $obits->search(last => 'Smith');
print $smiths[0]->{'url'}, "\n";
# Scalar context: first matching record, or undef
my $entry = $obits->search({ first => 'John', last => 'Smith' });
The returned hashrefs always include a C<url> key pointing to the source archive.
=over 4
=item * C<List context> - array of hashrefs, empty on no match.
=item * C<Scalar context> - single hashref, or C<undef> on no match.
=back
=head3 EXAMPLE
my @results = $obits->search(last => 'O-Brien');
foreach my $r (@results) {
printf "%s %s, age %s - %s\n",
$r->{first} // '?', $r->{last},
$r->{age} // 'unknown',
$r->{url};
}
# With optional filters
my $hit = $obits->search(first => 'John', middle => 'W', last => 'Coppage');
=head3 API SPECIFICATION
=head4 INPUT
{
'last' => {
type => 'string',
min => 1,
max => 100,
matches => qr/\A[\w-]+\z/ # hyphens allowed; \z rejects trailing newlines
},
'first' => {
type => 'string',
optional => 1,
min => 1,
max => 100
},
'middle' => {
type => 'string',
optional => 1,
min => 1,
max => 100
},
'age' => {
type => 'integer',
optional => 1,
min => 0,
max => 120
}
}
=head4 DOMAIN â last (required)
Boundary values
BVA MIN-1 "" (0 chars) INVALID â croak err_no_last
BVA MIN "A" (1 char) valid
BVA MAX "A"x100 (100 chars) valid
BVA MAX+1 "A"x101 (101 chars) INVALID â croak (schema max exceeded)
Equivalence partitions
EP-V "Smith" Typical ASCII surname.
EP-V "Smith-Jones" Hyphen is allowed (in [\w-]).
EP-V "Mc_Arthur" Underscore is \w.
EP-V "Smith2" Digit is \w.
EP-I undef Croak err_no_last.
EP-I "O'Brien" Apostrophe not in [\w\-] â rejected.
EP-I "van Berg" Space not in [\w\-] â rejected.
EP-I "Smith; DROP ..." SQL injection metacharacters rejected.
Character-domain (format partition)
FMT German umlauts (u-umlaut, sharp-s)
Matched by \w only when string has the UTF-8 flag
AND the calling program uses "use utf8" (or the
runtime locale enables Unicode semantics). Without
those, the same characters are rejected. No crash
either way; behaviour depends on runtime locale.
FMT Accented Latin (e.g. e-acute, n-tilde)
Same as German umlauts â locale-dependent.
FMT Emoji Not \w under any locale â always rejected.
FMT Zalgo combining marks Not \w â always rejected.
FMT RTL-override (U+202E) Not \w â always rejected.
FMT Full-width ASCII (e.g. U+FF33) Not \w â rejected.
Encoding note
The field value is stored and searched as received; the module does not
normalize Unicode (NFC/NFD) or transliterate diacritics. Ensure the caller
and the database were built with the same normalization if non-ASCII
surnames are used.
=head4 DOMAIN â first / middle (optional)
Boundary values
BVA MIN-1 "" (0 chars) INVALID â croak (schema min exceeded)
BVA MIN "J" (1 char) valid
BVA MAX "J"x100 (100 chars) valid
BVA MAX+1 "J"x101 (101 chars) INVALID
Equivalence partitions
EP-V Absent Valid â field is optional.
EP-V "John" Typical value.
EP-V "O'Malley" No format constraint on first/middle.
EP-I "" (empty string) INVALID (min=1).
Character-domain (format partition)
FMT ASCII text Always accepted within length limits.
FMT Non-ASCII / UTF-8 Accepted â no regex constraint on first/middle.
Diacritics, accented letters, and multibyte
sequences are passed through unchanged.
FMT Emoji Accepted syntactically; matched literally in
SQL LIKE comparisons (no normalization).
FMT Zalgo / RTL overrides Accepted syntactically; may produce unexpected
SQL matches or rendering artifacts.
Encoding note
first and middle are the safest fields for non-ASCII input: no regex
validation is applied and UTF-8 strings are stored and searched as-is.
Length is measured in Perl characters, not bytes; a 4-byte emoji counts
as 1 character toward the 100-character limit.
=head4 DOMAIN â age (optional integer)
Boundary values
BVA MIN-1 -1 INVALID â croak (schema min=0 exceeded)
BVA MIN 0 valid (newborn)
BVA MAX 120 valid (maximum recorded human lifespan)
BVA MAX+1 121 INVALID â croak (schema max exceeded)
Equivalence partitions
EP-V 65 Typical adult age.
EP-V Absent Valid â field is optional.
EP-I -1 Below minimum.
EP-I 121 Above maximum.
EP-I 1.5 Non-integer float â rejected (type=integer).
EP-I "old" Non-numeric string â rejected.
=head4 DOMAIN â invocation style
EP-V $obj->search(...) Normal object-method call.
EP-I Pkg->search(...) Croak err_no_self (class is not blessed).
EP-I Pkg::search(...) Croak err_no_self.
EP-I $obj->search() Croak err_no_args (zero args).
=head4 CONTEXT DOMAIN
List context Returns list of hashrefs; empty list on no match.
Scalar context Returns single hashref (first match) or undef.
Void context No crash; result silently discarded.
=head4 OUTPUT
Argument error: croak
No match (list): ()
No match (scalar): undef
Match (list): ( HashRef, ... ) each has a 'url' key
Match (scalar): HashRef has a 'url' key
=head3 MESSAGES
err_no_self - search() must be called on an object (->search, not ::search).
err_no_last - Value for 'last' is mandatory and must be non-empty.
err_no_obituaries - Cannot open the obituaries database; check directory path.
(from _create_url) err_bad_source, err_no_page, err_no_source, err_no_newspaper.
=head3 PSEUDOCODE
1. Croak unless $self is a blessed object.
2. Parse args with Params::Get; validate schema with Params::Validate::Strict.
3. Explicitly croak if 'last' is undef or empty - Params::Validate::Strict
passes undef through for defined-but-required fields.
4. Lazily open the obituaries DB handle (once per object lifetime).
5. Croak if the DB handle could not be initialised.
6. List context: fetchall, attach URL, fixate string values, return list.
7. Scalar context: fetchone, attach URL, fixate string values, return hashref.
8. Return undef / empty list when no rows match.
=cut | |||||
| 521 | ||||||
| 522 | sub search | |||||
| 523 | { | |||||
| 524 | 331 | 37717 | my $self = shift; | |||
| 525 | ||||||
| 526 | 331 | 422 | Carp::croak(__PACKAGE__->_i18n('err_no_self')) | |||
| 527 | unless Scalar::Util::blessed($self); | |||||
| 528 | ||||||
| 529 | # Guard against zero args before Params::Get, which uses confess (not croak) | |||||
| 530 | # for the 0-args case, preventing Test::Carp from detecting the error type. | |||||
| 531 | 323 | 310 | Carp::croak(__PACKAGE__->_i18n('err_no_args', {package => __PACKAGE__})) | |||
| 532 | unless @_; | |||||
| 533 | ||||||
| 534 | 316 | 290 | my $params = Params::Validate::Strict::validate_strict({ | |||
| 535 | args => Params::Get::get_params('last', @_), | |||||
| 536 | schema => { | |||||
| 537 | 'last' => { | |||||
| 538 | type => 'string', | |||||
| 539 | min => $MIN_LAST_NAME_LENGTH, | |||||
| 540 | max => $MAX_LAST_NAME_LENGTH, | |||||
| 541 | matches => qr/\A[\w-]+\z/, # Allow hyphens; \z rejects trailing newlines that \$ misses | |||||
| 542 | }, | |||||
| 543 | 'first' => { | |||||
| 544 | type => 'string', optional => 1, min => 1, max => 100, | |||||
| 545 | }, | |||||
| 546 | 'middle' => { | |||||
| 547 | type => 'string', optional => 1, min => 1, max => 100, | |||||
| 548 | }, | |||||
| 549 | 'age' => { | |||||
| 550 | type => 'integer', optional => 1, min => 0, max => 120, | |||||
| 551 | }, | |||||
| 552 | }, | |||||
| 553 | }); | |||||
| 554 | ||||||
| 555 | # Params::Validate::Strict enforces schema structure but passes undef values | |||||
| 556 | # for defined keys, so we check the mandatory 'last' field explicitly here. | |||||
| 557 | 261 | 29073 | unless(defined($params->{'last'}) && length($params->{'last'}) > 0) { | |||
| 558 | $self->{'logger'}->error(__PACKAGE__->_i18n('err_no_last')) | |||||
| 559 | 8 | 20 | if $self->{'logger'}; | |||
| 560 | 8 | 1933 | Carp::croak(__PACKAGE__->_i18n('err_no_last')); | |||
| 561 | } | |||||
| 562 | ||||||
| 563 | # Lazily initialise the DB handle â shared for the lifetime of the object | |||||
| 564 | $self->{'obituaries'} //= Genealogy::Obituary::Lookup::obituaries->new( | |||||
| 565 | no_entry => 1, | |||||
| 566 | no_fixate => 1, | |||||
| 567 | 253 132 | 307 244 | %{$self}, | |||
| 568 | ); | |||||
| 569 | ||||||
| 570 | 253 | 13208 | unless(defined $self->{'obituaries'}) { | |||
| 571 | $self->{'logger'}->error(__PACKAGE__->_i18n('err_no_obituaries')) | |||||
| 572 | 7 | 24 | if $self->{'logger'}; | |||
| 573 | 7 | 1584 | Carp::croak(__PACKAGE__->_i18n('err_no_obituaries')); | |||
| 574 | } | |||||
| 575 | ||||||
| 576 | 246 | 193 | if(wantarray) { | |||
| 577 | 165 | 199 | my $obituaries = $self->{'obituaries'}->selectall_hashref($params) | |||
| 578 | or return; | |||||
| 579 | # Iterate the arrayref directly (no intermediate grep list) and filter | |||||
| 580 | # undef slots inline â eliminates one O(N) allocation for large result sets. | |||||
| 581 | 157 | 10630 | my @rc; | |||
| 582 | 157 157 | 82 118 | for my $obit (@{$obituaries}) { | |||
| 583 | 181 | 103 | next unless defined $obit; | |||
| 584 | 173 | 159 | $obit->{'url'} = _create_url($obit); | |||
| 585 | # Intern string values in the hash to reduce memory for repeated | |||||
| 586 | # strings (source, place, newspaper) across large result sets. | |||||
| 587 | # fixate(%{$obit}) passes the hashref via the \[@%] prototype. | |||||
| 588 | # Guard with eval: if a value is already interned (read-only) a | |||||
| 589 | # second fixate call would otherwise die with "Modification of a | |||||
| 590 | # read-only value". Silently tolerate that case. | |||||
| 591 | 149 149 149 149 149 | 523 63 74 57 182 | { local $@; eval { Data::Reuse::fixate(%{$obit}) } }; | |||
| 592 | 149 | 9167 | push @rc, $obit; | |||
| 593 | } | |||||
| 594 | 133 | 188 | return @rc; | |||
| 595 | } | |||||
| 596 | ||||||
| 597 | 81 | 154 | my $obit = $self->{'obituaries'}->fetchrow_hashref($params) | |||
| 598 | or return; | |||||
| 599 | 32 | 2618 | $obit->{'url'} = _create_url($obit); | |||
| 600 | 29 29 29 29 29 | 110 22 12 19 40 | { local $@; eval { Data::Reuse::fixate(%{$obit}) } }; | |||
| 601 | ||||||
| 602 | 29 | 1759 | return Return::Set::set_return($obit, { type => 'hashref', min => 1 }); | |||
| 603 | } | |||||
| 604 | ||||||
| 605 | # --------------------------------------------------------------------------- | |||||
| 606 | # Private helpers | |||||
| 607 | # --------------------------------------------------------------------------- | |||||
| 608 | ||||||
| 609 | # Purpose: Builds the source URL for an obituary record. | |||||
| 610 | # Entry: $obit â hashref with 'source' (M/F/L), 'page', and optionally | |||||
| 611 | # 'newspaper' (required for source 'L'). | |||||
| 612 | # Exit: String URL. | |||||
| 613 | sub _create_url | |||||
| 614 | { | |||||
| 615 | # Enforce privacy: only code within this package may call _create_url. | |||||
| 616 | 208 | 3796 | Carp::croak("_create_url() is a private method of " . __PACKAGE__) | |||
| 617 | unless (caller)[0] eq __PACKAGE__; | |||||
| 618 | ||||||
| 619 | 205 | 1758 | my $obit = shift; | |||
| 620 | 205 | 127 | my $page = $obit->{'page'}; | |||
| 621 | 205 | 111 | my $source = $obit->{'source'}; | |||
| 622 | ||||||
| 623 | 205 | 148 | Carp::croak(__PACKAGE__->_i18n('err_no_page', {package => __PACKAGE__})) | |||
| 624 | unless defined $page; | |||||
| 625 | 200 | 148 | Carp::croak(__PACKAGE__->_i18n('err_no_source', {package => __PACKAGE__, page => $page})) | |||
| 626 | unless defined $source; | |||||
| 627 | ||||||
| 628 | # Premise: source â {M, F} returns unconditionally above. | |||||
| 629 | # Conclusion: the elsif below is only evaluated when source â {M, F}. | |||||
| 630 | 195 | 255 | if($source eq 'M' || $source eq 'F') { | |||
| 631 | 161 | 351 | return $URLS{$source} . $page; | |||
| 632 | } elsif($source eq 'L') { | |||||
| 633 | # 'L' (local/link) records embed the full URL in newspaper or page | |||||
| 634 | return $obit->{'newspaper'} | |||||
| 635 | 23 | 59 | if defined($obit->{'newspaper'}) && $obit->{'newspaper'} =~ m{\Ahttps?://}; | |||
| 636 | 14 | 26 | return $page | |||
| 637 | if $page =~ m{\Ahttps?://}; | |||||
| 638 | 6 | 14 | Carp::croak(__PACKAGE__->_i18n('err_no_newspaper', {package => __PACKAGE__})); | |||
| 639 | } | |||||
| 640 | ||||||
| 641 | 11 | 29 | Carp::croak(__PACKAGE__->_i18n('err_bad_source', | |||
| 642 | {package => __PACKAGE__, source => $source})); | |||||
| 643 | } | |||||
| 644 | ||||||
| 645 | # Purpose: Looks up a user-facing message template and interpolates named | |||||
| 646 | # placeholders of the form %{key} using the supplied args hashref. | |||||
| 647 | # Entry: $self_or_class â object or class name (not used yet; reserved for | |||||
| 648 | # per-instance locale configuration). | |||||
| 649 | # $key â key into %MESSAGES. | |||||
| 650 | # $args â optional hashref of placeholder values. | |||||
| 651 | # Exit: Formatted string. | |||||
| 652 | sub _i18n | |||||
| 653 | { | |||||
| 654 | # TODO: CLAUDE.md violation â _i18n() is missing the required caller() privacy | |||||
| 655 | # guard. Adding it breaks t/function.t, t/path.t, t/edge_cases.t, and | |||||
| 656 | # t/data-flow.t, which call _i18n() directly. Those tests must be refactored | |||||
| 657 | # to call through the public API before the guard can be re-introduced. | |||||
| 658 | # TODO: Data Flow Anomaly - $self_or_class defined (D) but never used (D~ dead store; reserved for future per-instance locale selection) | |||||
| 659 | 188 | 97266 | my ($self_or_class, $key, $args) = @_; | |||
| 660 | 188 | 286 | my $tpl = $MESSAGES{$key} | |||
| 661 | // Carp::croak("Unknown i18n key '$key'"); | |||||
| 662 | 185 | 173 | $args //= {}; | |||
| 663 | # Pre-populate replacement table from template keys so the substitution | |||||
| 664 | # needs no /e modifier â eliminates any eval of replacement text entirely. | |||||
| 665 | 185 | 95 | my %sub_vals; | |||
| 666 | 185 | 540 | $sub_vals{$1} = $args->{$1} // '' while $tpl =~ m/%\{(\w+)\}/g; | |||
| 667 | 185 | 336 | (my $msg = $tpl) =~ s/%\{(\w+)\}/$sub_vals{$1}/g; | |||
| 668 | 185 | 917 | return $msg; | |||
| 669 | } | |||||
| 670 | ||||||
| 671 - 893 | =head1 COMMON PITFALLS
=head2 Apostrophes are rejected in last names
The C<last> field is validated against C<qr/\A[\w-]+\z/>. This allows letters,
digits, underscores, and hyphens, but B<not> apostrophes. A search for
C<< last => "O'Brien" >> will croak at validation time. Use the closest
hyphenated or unhyphenated spelling:
$obits->search(last => 'OBrien'); # OK
$obits->search(last => "O'Brien"); # CROAKS
=head2 new() returns undef on a bad directory; it does not croak
When C<directory> is supplied but does not exist or is not readable, C<new()>
calls C<Carp::carp> (a warning, not a fatal error) and returns C<undef>.
Always check the return value before calling C<search()>:
my $obits = Genealogy::Obituary::Lookup->new(directory => $path)
or die "Could not open obituary database at $path";
=head2 Scalar vs list context returns different things
C<search()> is context-sensitive. In list context it returns every matching
record. In scalar context it returns only the first match. Assigning to a
plain variable is scalar context; assigning to an array is list context:
my @all = $obits->search(last => 'Smith'); # all records (list context)
my $first = $obits->search(last => 'Smith'); # one record (scalar context)
=head2 Clone semantics: the database handle is shared
Calling C<< $obj->new(...) >> creates a I<shallow copy> of the parent. If the
parent has already run a search (and therefore opened its C<obituaries> handle),
the clone starts out sharing that same handle object. The clone replaces the
handle on its first search call, but until then both objects reference the same
underlying driver. This is intentional and efficient; be aware of it if you
pass handles between threads or processes.
=head2 Search results are interned and become read-only
After C<search()> returns, all string values inside the result hashrefs are
interned by C<Data::Reuse::fixate>. Any attempt to modify them in place will
die with C<"Modification of a read-only value">:
my @hits = $obits->search(last => 'Smith');
$hits[0]->{last} = 'Jones'; # DIES -- read-only after search()
Copy the hashref or the field before modifying it:
my %copy = %{ $hits[0] };
$copy{last} = 'Jones'; # OK
=head2 Logger must implement info(), warn(), and error()
C<new()> validates the logger before storing it. The object must be blessed and
must implement B<all three> of C<info()>, C<warn()>, and C<error()>. An object
missing any one of them will cause C<new()> to croak immediately:
# CROAKS: object provides info() and error() but not warn()
my $obits = Genealogy::Obituary::Lookup->new(logger => $partial_logger);
C<warn()> is required because C<new()> uses it (not C<error()>) to report
non-fatal events such as a missing or unreadable directory. Using C<error()>
for those events would cause loggers whose C<error()> calls C<die> (such as
L<Log::Abstraction>) to convert a graceful C<carp + return undef> into a fatal
exception, breaking the documented API contract.
=head2 Non-ASCII characters in last depend on runtime locale
The C<[\w\-]+> regex matches C<\w>, which includes non-ASCII word characters
(accented letters, umlauts) when the string has the UTF-8 flag and the calling
code uses C<use utf8>. Without that, the same input is rejected. The module
does not set any locale; test explicitly if your data contains diacritics.
=head1 SECURITY NOTES
=head2 Null bytes in directory paths are rejected early
A C<directory> string containing a null byte (C<\0>) would cause Perl's
C<stat()> to throw a fatal C<"Embedded nulls are forbidden"> exception.
C<new()> detects this before the filesystem call, calls the logger's C<warn()>
method if a logger is present, and carps gracefully instead of dying.
=head2 Taint-mode readiness
The C<directory> argument is passed through a C<m/\A([^\0]*)\z/> capture before
any filesystem operator sees it. This untaints the value for callers running
under Perl's taint mode (C<perl -T>) without requiring any extra configuration.
=head2 URL construction uses percent-encoding
The database builder (C<bin/create_db.PL>) encodes user-controlled components
via C<URI::Escape::uri_escape> before embedding them in HTTP URLs. This
prevents surname values from being misinterpreted as URL structure.
=head2 Path traversal is prevented in the database builder
Environment variables C<MLARCHIVEDIR> and C<MLARCHIVE_DIR> are canonicalized
with C<File::Spec-E<gt>canonpath()> and then checked to confirm the resolved
path starts with the declared base directory. Any path that escapes the base
via C<../> components is rejected with C<croak>.
=head2 i18n substitution uses no eval
The C<_i18n()> helper pre-builds a substitution table from template
placeholders and then applies a plain C<s///g> replacement. No C</e> modifier
or string C<eval> is used, so template values cannot execute arbitrary code.
=head1 LIMITATIONS
=over 4
=item * B<Ancestry / Rootsweb archive loss.>
Only the first 18 pages of the mlarchives index are preserved on the Wayback
Machine. Approximately 10,000+ records from later pages are unrecoverable.
=item * B<No full-text search.>
Searches are keyed on structured fields (last, first, middle, age). There is
no free-text obituary content to search.
=item * B<i18n is English-only.>
The C<%MESSAGES> map supports placeholder interpolation but is not backed by a
locale-selection mechanism. A future release should route through
L<Locale::Maketext> or L<Locale::Simple>.
=item * B<Data::Reuse fixate semantics.>
The string-interning via C<Data::Reuse::fixate> on hash-slice aliases is
correct in theory (hash slices are lvalues) but depends on
C<String::Intern::Internalize> modifying @_ in place. Verify with your
installed version if memory consumption is a concern.
=item * B<Private method enforcement without Sub::Private.>
C<_create_url> and C<_i18n> enforce privacy via an inline C<caller> check.
Install L<Sub::Private> and replace the checks for a compile-time guarantee.
=item * B<Single-row scalar context.>
In scalar context, C<search()> returns the first row from the underlying
driver. Row order depends on L<Database::Abstraction> and SQLite's query
plan; add an explicit ORDER BY in the driver subclass if deterministic ordering
is required.
=back
=head1 AUTHOR
Nigel Horne, C<< <njh at nigelhorne.com> >>
=head1 BUGS
See L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Genealogy-Obituary-Lookup>.
=head1 SEE ALSO
L<Database::Abstraction>
=over 4
=item * The Obituary Daily Times: L<https://sites.rootsweb.com/~obituary/>
=item * Archived Rootsweb data: L<https://wayback.archive-it.org/20669/20231102044925/https://mlarchives.rootsweb.com/listindexes/emails?listname=gen-obit>
=item * Recent data: L<https://www.freelists.org/list/obitdailytimes>
=item * L<Configure an Object at Runtime|Object::Configure>
=item * L<Test Dashboard|https://nigelhorne.github.io/Genealogy-Obituary-Lookup/coverage/>
=back
=head1 SUPPORT
This module is provided as-is without any warranty.
perldoc Genealogy::Obituary::Lookup
=over 4
=item * MetaCPAN: L<https://metacpan.org/release/Genealogy-Obituary-Lookup>
=item * RT: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Genealogy-Obituary-Lookup>
=item * CPAN Testers' Matrix: L<http://matrix.cpantesters.org/?dist=Genealogy-Obituary-Lookup>
=back
=head1 FORMAL SPECIFICATION
=head2 new
ððð : Class à Args â (Object ⪠{â¥})
ððð(C, A) â
let D = A.directory ⨠dir_cache(C) { dir_cache memoises module_data_path(C) }
in A.logger â â
⧠¬(can(A.logger,'info') â§
can(A.logger,'error')) â¹ abort
⥠is_string(D) â§ null_byte(D) â¹ â¥
⥠is_string(D) â¹ D â untaint(D) { guaranteed: no null bytes }
⥠¬readable(D) â¹ â¥
⥠otherwise ⹠⨠cache_duration ⦠DEFAULT_CACHE_DURATION â© â A
where dir_cache(C) â state map C ⦠module_data_path(C) { per-class, per-process }
=head2 search
ðððððð : Object à Params â ([Obit] ⪠Obit ⪠{undef})
ðððððð(self, P) â
pre blessed(self) â§ P.last â â
post wantarray â¹ { o : Obit | match(self.db, P) } |> map(add_url)
else â¹ head({ o : Obit | match(self.db, P) } |> map(add_url))
where add_url(o) â o â ⨠url ⦠_create_url(o) â©
=head1 LICENSE AND COPYRIGHT
Copyright 2020-2026 Nigel Horne.
Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.
=cut | |||||
| 894 | ||||||
| 895 | 1; | |||||