NAME
Genealogy::Wills - Search a local database of historical wills
VERSION
Version 0.11
DESCRIPTION
A "will" (short for "last will and testament") is a legal document in which a person states who should receive their money and property after they die. Courts record when a will is officially accepted (called "probate"). This module gives you a simple way to search those records.
The data comes from the Kent Wills Transcript, a free online collection of wills proved in Kent, covering roughly the 1500s through the 1900s. That data is stored in a local SQLite file (wills.sql), so no internet connection is needed when you run a search. The database is built once by running perl bin/create_db.PL.
Each record in the database describes one will and contains:
The person's first name, optional middle name, and last name (surname).
The town where the person lived or died.
The year the will was proved (officially accepted by the court).
A URL linking to the original entry on the Kent Wills Transcript website.
Using the module is a two-step process: create one Genealogy::Wills object with new(), then call search() as many times as you like.
SYNOPSIS
use Genealogy::Wills;
# -------------------------------------------------------------------
# Example 1: Find all records for a given last name.
# -------------------------------------------------------------------
my $wills = Genealogy::Wills->new();
die "Could not load wills database" unless defined $wills;
my @smiths = $wills->search(last => 'Smith');
for my $r (@smiths) {
printf "%s %s, %s (%d)\n %s\n\n",
$r->{first}, $r->{last},
$r->{town}, $r->{year},
$r->{url};
}
# -------------------------------------------------------------------
# Example 2: Short form -- pass just the last name as a plain string.
# -------------------------------------------------------------------
my @joneses = $wills->search('Jones');
# -------------------------------------------------------------------
# Example 3: Narrow by first name, town, and year.
# -------------------------------------------------------------------
my @johns = $wills->search(
first => 'John',
last => 'Smith',
town => 'Canterbury, Kent, England',
year => 1750,
);
# -------------------------------------------------------------------
# Example 4: Scalar context -- get only the first matching record.
# Use this when you want one result, not a list.
# -------------------------------------------------------------------
my $will = $wills->search(last => 'Carlton');
if (defined $will) {
print "First match: $will->{first} $will->{last} ($will->{year})\n";
print "See: $will->{url}\n";
} else {
print "No record found.\n";
}
# -------------------------------------------------------------------
# Example 5: Check whether anything was found.
# -------------------------------------------------------------------
my @results = $wills->search(last => 'Xyz');
if (@results) {
print scalar(@results), " records found.\n";
} else {
print "No records found.\n";
}
# -------------------------------------------------------------------
# Example 6: Point at a different database directory.
# -------------------------------------------------------------------
my $wills2 = Genealogy::Wills->new(directory => '/var/data/wills');
# -------------------------------------------------------------------
# Example 7: Load settings from a config file.
# The YAML key must use double underscores: Genealogy__Wills
# -------------------------------------------------------------------
# Contents of /etc/wills.yml:
# Genealogy__Wills:
# directory: /var/data/wills
my $wills3 = Genealogy::Wills->new(config_file => '/etc/wills.yml');
# -------------------------------------------------------------------
# Example 8: Clone an existing object, changing one setting.
# -------------------------------------------------------------------
my $wills4 = $wills->new(cache_duration => '12 hours');
SUBROUTINES/METHODS
new
Creates and returns a Genealogy::Wills object.
No arguments are required. All arguments are optional and may be passed in any of these forms:
Genealogy::Wills->new() # no arguments
Genealogy::Wills->new(key => value, ...) # flat key-value list
Genealogy::Wills->new({ key => value, ... }) # hash reference
Genealogy::Wills->new('/path/to/data') # single string = directory
Returns the new object on success, or undef on failure (for example, if the database directory does not exist). A warning is printed to explain what went wrong.
Always check the return value before calling search(). If new() returns undef and you ignore it, a call to search() will crash your program later with a confusing error.
my $wills = Genealogy::Wills->new();
die "Could not load database" unless defined $wills;
ARGUMENTS
directory(optional, string)The path to the directory that contains the
wills.sqlfile.If you do not provide this, the module uses its own built-in data directory, which is set up when you run
perl bin/create_db.PL.You can also pass a single bare string as a shortcut:
Genealogy::Wills->new('/path/to/data') # is the same as Genealogy::Wills->new(directory => '/path/to/data')config_file(optional, string)Path to a configuration file in
YAML,XML,INI, or another format supported by Object::Configure.The file must have a top-level section named
Genealogy__Wills(the class name with::replaced by__). For example:# /etc/wills.yml Genealogy__Wills: directory: /var/data/willsEnvironment variables of the form
Genealogy__Wills__keyoverride values read from the file. For example, settingGenealogy__Wills__directory=/tmp/willsin the shell overrides thedirectoryfrom the file.Croaks (the program stops with an error message) if you provide a
config_filepath that does not exist or cannot be read.logger(optional, object)An object used to write diagnostic messages. It must have both an
info()method and anerror()method. Any Log::Log4perl logger, or any object that implements those two methods, works.Croaks if an object is provided but is missing the required methods.
cache_duration(optional, string)How long the underlying
Database::Abstractionlayer caches query results. Default is'1 day'. Accepts strings like'12 hours','2 days'.
RETURNS
On success: a Genealogy::Wills object.
On failure: undef, with a warning printed to STDERR naming the problem.
EXAMPLE
# Minimal -- uses the bundled database
my $w = Genealogy::Wills->new();
die "Failed to load database" unless defined $w;
# Explicit directory path
my $w = Genealogy::Wills->new(directory => '/data/kent-wills');
# Hash-reference form (same result)
my $w = Genealogy::Wills->new({ directory => '/data/kent-wills' });
# Single-string shortcut (treated as the directory path)
my $w = Genealogy::Wills->new('/data/kent-wills');
# From a YAML config file
my $w = Genealogy::Wills->new(config_file => '/etc/wills.yml');
# Clone an existing object, changing the cache duration
my $w2 = $w->new(cache_duration => '12 hours');
API SPECIFICATION
input
new() uses Params::Get to normalize its arguments but does not apply Params::Validate::Strict validation. The recognized parameters are:
# Params::Get::get_params(undef, @_) -- normalizes to a hashref.
# Accepted as: flat list, hash reference, or a single string (= directory).
{
directory => { type => 'string' }, # readable directory path
config_file => { type => 'string' }, # readable path; croaks if missing
logger => { type => 'object',
can => [ 'info', 'error' ],
optional => 1
},
cache_duration => { type => 'string',
default => '1 day',
optional => 1
},
}
output
# Return::Set is not used by new().
{
type => 'hashref',
optional => 1
}
MESSAGES
- Can't load configuration from <path>
-
Fatal. The
config_filepath was given but the file is missing or unreadable. Check the path and file permissions. - Logger must be an object with info() and error() methods
-
Fatal. The
loggerargument is not an object, or is missinginfo()orerror(). Pass a compatible logger such as Log::Log4perl. - Genealogy::Wills: <dir> is not a directory
-
Warning (not fatal). The resolved directory does not exist or cannot be read.
new()returnsundef. Verify the path; if using the bundled database runperl bin/create_db.PL.
search
Search the wills database for records that match the criteria you provide.
The last name (last) is the only required field. All other fields are optional and narrow the results further. If more than one field is given, a record must match all of them to be returned.
Important -- context matters: what you get back depends on how you call the method:
List context (
my @results = $w->search(...))Returns all matching records as a list of hash references. Returns an empty list (
()) when nothing matches.Scalar context (
my $result = $w->search(...))Returns one hash reference (the first match found), or
undefwhen nothing matches.
Each returned hash reference has these keys:
first -- first name (string)
last -- last name (string)
middle -- middle name (string, or undef if not recorded)
town -- town, e.g. "Canterbury, Kent, England" (string, or undef)
year -- year the will was proved (integer, or undef)
url -- full URL to the source page, e.g. "https://freepages..."
The url field always starts with https://.
ARGUMENTS
last(required, string)The surname (last name, family name) to search for.
Must be non-empty and contain only letters, digits, underscores (the
\wset), and hyphens. Any other characters (including apostrophes) cause the call to fail validation. For example,"O'Brien"must be passed as"OBrien".The search is exact-match:
"Smith"finds only the exact string"Smith", not"Smithson".first(optional, string, 1-100 characters)The first name (given name) to filter by.
middle(optional, string, 1-100 characters)The middle name to filter by.
town(optional, string, 1-100 characters)The town to filter by. Records use the format
"Townname, Kent, England". Use the exact spelling seen in earlier results, or leave this out and narrow by other fields instead.year(optional, integer, 1 to current year)The year the will was proved. Must be a positive whole number no greater than the current calendar year.
You may pass arguments in three ways:
$w->search(last => 'Smith') # flat key-value list
$w->search({ last => 'Smith' }) # hash reference
$w->search('Smith') # bare string = last name only
RETURNS
List context: a list of hash references (may be empty).
Scalar context: one hash reference, or undef if nothing matched.
Each hash reference has these keys: first, last, middle, town, year, url.
EXAMPLE
my $w = Genealogy::Wills->new();
# All records for the surname "Cowell"
my @all = $w->search(last => 'Cowell');
print scalar(@all), " records found.\n";
# Bare string shortcut
my @smiths = $w->search('Smith');
# Multiple filters
my @hits = $w->search(
first => 'Stephen',
last => 'Carlton',
town => 'Ash, Kent, England',
);
# Scalar context: one result or undef
my $one = $w->search(last => 'Horne');
if (defined $one) {
printf "%s %s (%d): %s\n",
$one->{first}, $one->{last}, $one->{year}, $one->{url};
}
# url always starts with https://
for my $r ($w->search(last => 'Smith')) {
print $r->{url}, "\n";
}
API SPECIFICATION
input
schema => {
last => { type => 'string',
min => 1, max => 100,
matches => qr/^[\w-]+\z/a },
first => { type => 'string',
min => 1, max => 100,
optional => 1 },
middle => { type => 'string',
min => 1, max => 100,
optional => 1 },
town => { type => 'string',
min => 1, max => 100,
optional => 1 },
year => { type => 'integer',
min => 1, max => $MAX_WILL_YEAR,
optional => 1 },
};
$MAX_WILL_YEAR is (localtime)[5] + 1900 computed once at module load. Params::Get::get_params('last', ...) maps a bare string argument to { last => $string } before validation runs. The schema hashref itself is a module-level constant allocated once at load time and shared across all calls; it is never modified at runtime.
output
# List context -- no Return::Set wrapping
Returns: Array of HashRef
Each HashRef: { first => { type => 'string', optional => 1 },
last => { type => 'string' },
middle => { type => 'string', optional => 1 },
town => { type => 'string', optional => 1 },
year => { type => 'integer', optional => 1 },
url => { type => 'string', matches => qr/^https:\/\// },
}
Empty list when nothing matches.
# Scalar context -- wrapped by Return::Set
Return::Set::set_return($will, { type => 'hashref', min => 1 });
Returns: HashRef (same shape) | undef
MESSAGES
- search() must be called on an object
-
Fatal. You called
search()as a class method (Genealogy::Wills->search(...)) instead of on an object. Create the object first withnew(), then callsearch()on it. - Usage: search({ last => $last_name })
-
Fatal.
search()was called with no arguments at all. Always provide at leastlast => $name. - Value for 'last' is mandatory
-
Warning (not fatal). The
lastargument was provided but its value wasundefor an empty string.search()returns no results in this case. Supply a non-empty string. - Can't open the wills database
-
Fatal. The internal database object could not be created. The database file may be missing or corrupted. Rebuild with
perl bin/create_db.PL -f.
PSEUDOCODE
A plain-English description of what search() does, step by step:
1. If the caller is not a Genealogy::Wills object, die immediately.
2. If no arguments were given, die immediately.
3. Parse the arguments:
- A single bare string becomes { last => $string }.
- A hash reference or flat key-value list is used as-is.
4. Validate the parsed arguments:
- 'last' must match /^[\w-]+$/ and be 1-100 characters.
- 'first', 'middle', 'town': optional strings, 1-100 characters each.
- 'year': optional integer between 1 and the current year.
5. If 'last' is undef or empty after parsing, print a warning and return
nothing (an empty list or undef, depending on context).
6. (Removed: sanitization was a no-op. Validation in step 4 already
enforces [\w-] only via PVS matches => qr/^[\w-]+\z/a.)
7. If this is the first search() call on this object, open the SQLite
database. Reuse the existing connection on subsequent calls.
8. If the database could not be opened, die immediately.
9. Execute the query:
List context:
Fetch all matching rows.
Prepend "https://" to the url of every row.
Intern all strings (Data::Reuse::fixate) to save memory.
Return the list of hashrefs.
Scalar context:
Fetch the first matching row.
Prepend "https://" to its url.
Intern all strings.
Return the hashref (or undef if nothing matched).
COMMON PITFALLS
This section describes the most common mistakes when using this module. Read it before reporting a bug.
List context vs. scalar context give different results
This is the single most important thing to understand. The same call returns different things depending on whether you store the result in an array or a scalar variable:
my @all = $wills->search(last => 'Smith'); # ALL Smiths (may be many)
my $first = $wills->search(last => 'Smith'); # ONE Smith only
If you accidentally write my $r = $wills->search(...), you get at most one record even if hundreds matched. Use my @results = ... unless you specifically want only the first match.
new() returns undef on failure -- it does not crash
If the directory does not exist or cannot be read, new() prints a warning and returns undef. Your program does not stop. If you then call search() on the undef value, it will crash later with an unhelpful error.
Always check the return value:
my $wills = Genealogy::Wills->new();
die "Could not load wills database" unless defined $wills;
No arguments is fatal; undef is only a warning
These two situations look similar but have very different consequences:
$wills->search(); # FATAL -- program stops with an exception
$wills->search(last => undef); # WARNING only -- returns no results
Always provide at least last => $name.
The url field already contains https://
Every returned record has its url field set to a full URL starting with https://. Do not add the scheme prefix yourself:
print $r->{url}; # correct: https://freepages.rootsweb.com/...
print 'https://' . $r->{url}; # WRONG: https://https://freepages...
Apostrophes and punctuation are rejected in last names
The module accepts only word characters (\w) and hyphens in the last argument. Any other character -- including apostrophes -- causes validation to fail, not silent stripping.
$wills->search(last => "O'Brien"); # FAILS validation; pass "OBrien"
If the record you are looking for has a name like O'Brien, search for OBrien instead (that is how it was recorded in the database).
Search is exact-match -- no wildcards or fuzzy matching
The query looks for an exact match on every field you provide. Partial last names and wildcard patterns (such as Smith*) are not supported through this interface.
$wills->search(last => 'Smith'); # finds "Smith" only
# Does NOT find "Smithson", "Blacksmith", "Goldsmith", etc.
The config file key uses double underscores, not colons
When using a YAML (or other) config file, the section name for this class must use two underscores in place of each :: in the package name:
# CORRECT
Genealogy__Wills:
directory: /var/data/wills
# WRONG (causes the config to be silently ignored)
Genealogy::Wills:
directory: /var/data/wills
The same rule applies to environment variable overrides: Genealogy__Wills__directory=/tmp/wills.
Do not use the function-call syntax with arguments
Calling new() as a plain function (:: instead of ->) with arguments does not work correctly. The first argument is misread as the class name.
Genealogy::Wills->new() # correct -- arrow syntax
Genealogy::Wills::new() # tolerated -- no args, defaults to package
Genealogy::Wills::new('/data') # WRONG -- '/data' is misused as class name
The year upper limit is fixed when the module loads
The maximum allowed year value is computed once the first time use Genealogy::Wills is executed. If your process runs for a very long time and crosses a year boundary (e.g. from 31 December to 1 January), the cap will be stale by one year until the module is reloaded.
LIMITATIONS
Only data from Kent is available at the moment.
::new()with arguments is unsupported.Genealogy::Wills::new('Smith')shifts'Smith'into$classand attempts to bless into it. Only the no-argument formGenealogy::Wills::new()is partially handled (it defaults to__PACKAGE__). Always use the arrow form:Genealogy::Wills->new().Year upper bound is capped at load time.
MAX_WILL_YEARis computed once when the module is first loaded. In the unlikely event the module remains loaded across a year boundary the cap will be one year stale.No full-text or fuzzy search.
Searches are exact-match on the columns provided. There is no fuzzy or phonetic matching (e.g. Soundex, Levenshtein distance). Wildcard support depends on the
Database::Abstractionlayer.The
loggerargument is silently discarded.Object::Configurealways supplies its ownLog::Abstractionlogger. Any object passed asloggertonew()is replaced before it is stored. See the "SECURITY" section, Finding 3.Single database source.
The data comes from a single scraped source (the Kent Wills Transcript). It does not cover wills from other counties or archives.
SECURITY
This section documents the attack surface of the module, the controls in place, and known open findings. It is intended for developers integrating this module into a web application or CGI script.
Attack surface summary
The module has two entry points: new() and search(). Neither reads from %ENV, STDIN, or any HTTP source directly. In a CGI context, the calling script is responsible for parsing HTTP inputs before passing them to this module.
Controls in place
lastis strictly validatedParams::Validate::Strictenforcesmatches => qr/^[\w-]+\z/aon thelastargument before any database call. This blocks SQL injection, XSS, shell metacharacters, CRLF sequences, and null bytes for that field. The/amodifier restricts\wto ASCII[0-9A-Za-z_], blocking Unicode homograph queries. The\zanchor is strictly end-of-string, unlike$which matches before a trailing newline.yearis range-validated as an integerParams::Validate::Strictenforcestype => 'integer',min => 1, andmax => MAX_WILL_YEAR. Non-integer strings, floats, and out-of-range values are all rejected before DB access.Directory and config-file paths are checked before use
new()verifies-d $dir && -r _for the data directory and-rfor anyconfig_file. Paths that do not pass these checks causenew()to returnundef(with a warning) or croak immediately.Logger injection is blocked by Object::Configure
Object::Configure::configure()always replaces the caller-suppliedloggerargument with its ownLog::Abstractionobject, regardless of what was passed. A hostile logger (missing methods, wrong type, or carrying malicious extra methods) is discarded before any logging call occurs. The module-level interface check(blessed && can 'info' && can 'error')runs against theObject::Configure-supplied logger, which always passes.No shell operations
The module performs no
system(),exec(), backtick, oropen(FH, "...|")calls. Shell metacharacters in any field pose no command-injection risk within this module.Database access is parameterised
All SQL is executed via
Database::Abstraction(v0.37+), which uses DBI prepared statements. Values are bound as parameters, not interpolated into SQL strings.
Known findings
Finding 1 (fixed):
first,middle,townnow havematchesconstraintsPreviously these optional fields were validated for type and length only, allowing SQL metacharacters (
;,=,|,<,>,\r,\n,\0) to reach the database layer verbatim.Applied fix: the schema now enforces:
first/middle: matches => qr/^[\w '.-]+\z/ town: matches => qr/^[\w ',.-]+\z/These allow legitimate name characters (Unicode word chars, space, apostrophe as in
O'Brien, period as inSt. John, hyphen as inMary-Anne, comma in town as in"Canterbury, Kent, England") while blocking injection metacharacters.Residual surface: the pattern
Smith'--, which contains only apostrophe and hyphens, passes the constraint. It is neutralised byDatabase::Abstraction's parameterised queries - the value is bound as a literal string, never interpolated into SQL.Primary defence: parameterised queries (
Database::Abstraction). Thematchesconstraint is defence-in-depth only.Test coverage:
t/cgi_security.t, section 10.Finding 4:
Genealogy__Wills__directoryenvironment variable can redirect the data directoryObject::Configurereads environment variables of the formGenealogy__Wills__keyand merges them into the configuration beforenew()applies defaults. SettingGenealogy__Wills__directory=/attacker/pathin the process environment redirectsnew()to any SQLite file the attacker can create at that path, causingsearch()to return results from a malicious database.Conditions required: the attacker must control the process environment (
%ENV) beforenew()is called. In a CGI context this requires compromising the web server's environment-variable namespace, not merely sending HTTP headers.Mitigation: ensure the web server or process supervisor sanitises
%ENVbefore execution. The-d $dir && -r _check innew()prevents non-existent paths but does not block a readable attacker-controlled directory.Test coverage: none (requires process-level ENV control; mocked ENV in tests does not reach
Object::Configure).Finding 2 (fixed):
matches => qr/^[\w-]+\z/a- Unicode and trailing-newline issues resolvedThe
\wclass without/amatched Unicode word characters (Cyrillic, Greek, Hebrew, etc.), allowing a homograph query such as"\x{0430}mith"to pass validation silently (returning zero results, not an error).Additionally, the former
$anchor matches before a trailing\n, meaning"Smith\n"would have passed the pattern.Applied fix: the schema now uses
qr/^[\w-]+\z/a:/arestricts\wto ASCII[0-9A-Za-z_], and\zanchors to the absolute end-of-string with no\nexception. The\-escape inside[]has also been normalised to the idiomatic unescaped-at the end of the character class.Test coverage:
t/cgi_security.t, section 19.Finding 3: caller-supplied logger is silently discarded
Object::Configurealways replaces theloggerargument with aLog::Abstractioninstance. A caller who passes a custom logger (for example, aLog::Log4perlobject) will find it silently ignored. The POD documents this parameter, but its effect is a no-op.Impact: this is a usability limitation, not a security risk.
Test coverage:
t/cgi_security.t, section 15.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
BUGS
Please report bugs at https://rt.cpan.org/NoAuth/Bugs.html?Dist=Genealogy-Wills or by email to bug-Genealogy-Wills@rt.cpan.org.
When reporting a bug, please include:
The version of this module (run
perl -MGenealogy::Wills -e 'print $Genealogy::Wills::VERSION').The version of Perl (run
perl -V).A short script that shows the problem.
SEE ALSO
The Kent Wills Transcript
https://freepages.rootsweb.com/~mrawson/genealogy/wills.html
Database::Abstraction -- the SQL layer used internally by this module.
Return::Set -- enforces return-type contracts on
search().
SUPPORT
This module is provided as-is without any warranty.
You can find documentation for this module with the perldoc command:
perldoc Genealogy::Wills
Other resources:
MetaCPAN
RT: CPAN's request tracker
CPAN Testers' Matrix
CPAN Testers Dependencies
FORMAL SPECIFICATION
System-level Z-notation for Genealogy::Wills. The search() function's specification also appears in detail under its own section.
-- Scalar type definitions
NAME == seq₁ CHAR -- non-empty character sequence
PATHNAME == seq₁ CHAR -- non-empty filesystem path
YEAR == 1 .. MaxYear -- positive integer up to the current year
-- The database object created by new()
WillsDatabase
directory : PATHNAME
cache_duration : seq CHAR
records : ℙ WillRecord
-- Successful construction invariant
┌ InitWillsDatabase ─────────────────────────────────┐
│ WillsDatabase │
│ ────────────────────────────────────────────────── │
│ ∃ d : PATHNAME • directory = d ∧ is_readable(d) │
│ cache_duration = "1 day" │
│ records = load_sqlite(directory ++ "/wills.sql") │
└─────────────────────────────────────────────────────┘
search
The following Z-notation gives the precise mathematical meaning of search(). Optional fields that the caller did not supply are modelled as undefined.
-- Type aliases
NAME == seq₁ CHAR -- a non-empty sequence of characters
YEAR == 1 .. MaxYear -- positive integer bounded by current year
-- One record stored in the database
WillRecord
first : NAME
last : NAME
middle : NAME | undefined
town : NAME | undefined
year : YEAR | undefined
url : NAME -- stored without "https://"; prefixed by search()
-- Parameters passed to search()
SearchParams
last : NAME
first : NAME | undefined
middle : NAME | undefined
town : NAME | undefined
year : YEAR | undefined
-- Invariant: last name must be non-empty
┌ SearchParams ──────────────────┐
│ last : NAME │
│ ───────────────────────────── │
│ last ≠ ⟨⟩ │
└────────────────────────────────┘
-- Predicate: does record r satisfy all supplied parameters?
matches : WillRecord × SearchParams → BOOL
matches(r, p) ==
r.last = p.last
∧ (p.first = undefined ∨ r.first = p.first)
∧ (p.middle = undefined ∨ r.middle = p.middle)
∧ (p.town = undefined ∨ r.town = p.town)
∧ (p.year = undefined ∨ r.year = p.year)
-- Function signature
search : WillsDatabase × SearchParams → ℙ WillRecord
-- When last is non-empty, return all records that match
∀ db : WillsDatabase; p : SearchParams •
p.last ≠ ⟨⟩ ⟹
search(db, p) = { r : WillRecord | r ∈ db.records ∧ matches(r, p) }
-- When last is empty, return nothing
∀ db : WillsDatabase; p : SearchParams •
p.last = ⟨⟩ ⟹
search(db, p) = ∅
LICENSE AND COPYRIGHT
Copyright 2023-2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.