lib/Database/Join.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 97.17%
TER3 (LCSAJ): 100.0% (20/20)
Approximate LCSAJ segments: 107

LCSAJ Legend

Covered — this LCSAJ path was executed during testing.

Not covered — this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start → end → jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package Database::Join;
    2: 
    3: # ABSTRACT: Combined view across two or more Database::Abstraction objects
    4: 
    5: use strict;
    6: use warnings;
    7: use autodie qw(:all);
    8: 
    9: use Carp qw(croak carp);
   10: use List::Util qw(max);
   11: use Readonly;
   12: use Scalar::Util qw(blessed);
   13: use Object::Configure;
   14: use Params::Get qw(get_params);
   15: use Params::Validate::Strict qw(validate_strict);
   16: use Sub::Protected;
   17: 
   18: # Named-pair keys accepted by add_database; kept here so the guard and the
   19: # validate_strict schema cannot silently diverge.
   20: Readonly::Array my @_ADD_DB_KEYS => qw(database join_column filter remove_columns);
   21: 
   22: our $VERSION = '0.001.0';
   23: 
   24: # ---------------------------------------------------------------------------
   25: # All user-facing strings route through this dictionary.  Supply an i18n
   26: # object with a translate($key, @sprintf_args) method to localise them.
   27: # ---------------------------------------------------------------------------
   28: Readonly::Hash my %MESSAGES => (
   29: 	error_no_databases	=> 'At least one Database::Abstraction object is required',
   30: 	error_invalid_db	=> 'databases[%d] is not a Database::Abstraction object',
   31: 	error_join_col_missing	=> 'join_column "%s" is absent from databases[%d] (%s)',
   32: 	error_col_conflict	=> 'Column "%s" exists in multiple databases; use the owning database directly or rename the column',
   33: 	error_remove_join_col	=> 'Cannot remove join_column "%s"; it is required for the join',
   34: 	warn_unknown_column	=> 'Column "%s" is not present in any configured database; criterion ignored',
   35: 	error_query_unsupported	=> 'query() chained builder is not supported on Database::Join; call selectall_arrayref / fetchrow_hashref directly',
   36: 	error_execute_unsupported => 'execute() raw SQL is not supported on Database::Join',
   37: 	error_unknown_message	=> 'Unknown message key "%s"',
   38: );
   39: 
   40: =head1 NAME
   41: 
   42: Database::Join - Read-only combined view across two or more Database::Abstraction objects
   43: 
   44: =head1 VERSION
   45: 
   46: Version 0.001.0
   47: 
   48: =head1 SYNOPSIS
   49: 
   50: B<Basic two-database join>
   51: 
   52:     use Database::Join;
   53: 
   54:     # Step 1: create each component database the normal way
   55:     my $customers = Database::Customers->new(directory => '/data');
   56:     my $loyalty   = Database::Loyalty->new(directory  => '/data');
   57: 
   58:     # Step 2: combine them on the shared key column 'entry'
   59:     my $join = Database::Join->new(
   60:         databases   => [ $customers, $loyalty ],
   61:         join_column => 'entry',
   62:     );
   63: 
   64:     # Step 3: query exactly as you would a single Database::Abstraction object
   65:     my $all_rows  = $join->selectall_arrayref();
   66:     my $vip_rows  = $join->selectall_arrayref(tier => 'gold');
   67:     my $one_row   = $join->fetchrow_hashref(entry => 'C001');
   68:     my $total     = $join->count();
   69:     my $col_names = $join->columns();
   70: 
   71: B<Hiding internal columns>
   72: 
   73:     my $join = Database::Join->new(
   74:         databases      => [ $customers, $loyalty ],
   75:         join_column    => 'entry',
   76:         remove_columns => [ 'internal_id', 'audit_ts' ],
   77:     );
   78:     # 'internal_id' and 'audit_ts' never appear in results or columns()
   79: 
   80: B<join_map: when the key column has different names in each database>
   81: 
   82:     # $cities  (index 0) has a column called 'statecode' -- matches join_column
   83:     # $stnames (index 1) has a column called 'entry'     -- different name
   84: 
   85:     my $join = Database::Join->new(
   86:         databases   => [ $cities,  $stnames ],
   87:         #                index 0   index 1
   88:         join_column => 'statecode',
   89:         join_map    => { 1 => 'entry' },  # index 1 calls its join key 'entry'
   90:     );
   91: 
   92:     # All returned rows use 'statecode'; 'entry' is never exposed
   93:     my $rows = $join->selectall_arrayref();
   94: 
   95: B<filters: permanently restrict a database's visible rows>
   96: 
   97:     # Only show orders placed more than 60 days ago, without repeating
   98:     # the criterion on every query call.
   99:     my $join = Database::Join->new(
  100:         databases   => [ $customers, $orders ],
  101:         join_column => 'entry',
  102:         filters     => { 1 => { age_days => { '>' => 60 } } },
  103:     );
  104: 
  105:     my $rows = $join->selectall_arrayref();                 # all old orders
  106:     my $vip  = $join->selectall_arrayref(tier => 'gold');   # old + gold tier
  107: 
  108: B<Inner and outer join types>
  109: 
  110:     my $inner = Database::Join->new(
  111:         databases   => [ $customers, $loyalty ],
  112:         join_column => 'entry',
  113:         join_type   => 'inner',   # only keys present in BOTH databases
  114:     );
  115: 
  116:     my $outer = Database::Join->new(
  117:         databases   => [ $customers, $loyalty ],
  118:         join_column => 'entry',
  119:         join_type   => 'outer',   # all keys from EITHER database
  120:     );
  121: 
  122: B<Building the view incrementally with add_database>
  123: 
  124:     my $join = Database::Join->new(
  125:         databases   => [ $customers ],
  126:         join_column => 'entry',
  127:     );
  128: 
  129:     $join->add_database($loyalty)
  130:          ->add_database($scores, remove_columns => ['raw_score']);
  131: 
  132: B<AUTOLOAD column shortcut>
  133: 
  134:     # Returns the 'name' value for entry 'C001' (scalar context)
  135:     my $name = $join->name(entry => 'C001');
  136: 
  137:     # Returns all 'tier' values (list context)
  138:     my @tiers = $join->tier();
  139: 
  140: =head1 DESCRIPTION
  141: 
  142: C<Database::Join> merges two or more L<Database::Abstraction> objects into a
  143: single logical, read-only view.  Each component database is queried
  144: independently through its own C<Database::Abstraction> interface.  The results
  145: are combined in Perl memory using a shared key column (C<join_column>).
  146: 
  147: The module exposes the same read-only API as C<Database::Abstraction>:
  148: C<selectall_arrayref>, C<selectall_array>, C<fetchrow_hashref>, C<count>,
  149: C<columns>, C<schema>, C<updated>, C<set_logger>, and the AUTOLOAD column
  150: shortcut.  Callers do not need to know how many underlying databases are
  151: involved.
  152: 
  153: Think of it as a virtual database table that is assembled on demand from
  154: several real tables, one per component database.
  155: 
  156: =head2 Join semantics
  157: 
  158: The C<join_type> parameter controls what happens when a particular key value
  159: exists in some component databases but not all:
  160: 
  161: =over 4
  162: 
  163: =item C<left> (the default)
  164: 
  165: All rows from the I<primary> (first) database are returned.  Columns from
  166: subsequent databases are included where a matching row is found, and simply
  167: absent from the hashref where there is no match.  If you are familiar with
  168: SQL, this is a LEFT OUTER JOIN on the first table.
  169: 
  170: =item C<inner>
  171: 
  172: Only rows whose join-column value is present in I<every> component database
  173: are returned.  This is equivalent to a SQL INNER JOIN.
  174: 
  175: =item C<outer>
  176: 
  177: Every join-column value found in I<any> component database is returned.
  178: Columns from databases that do not have that key value are absent from the
  179: merged row.  This is a FULL OUTER JOIN.
  180: 
  181: =back
  182: 
  183: B<Important override rule:> whenever you pass a query criterion for a column
  184: that belongs to a secondary database, that database automatically acts as an
  185: inner-join partner for that query only -- regardless of C<join_type>.  This
  186: gives WHERE-clause semantics.  For example, if you have a LEFT join but query
  187: C<< tier => 'gold' >> on a secondary database, only rows whose secondary entry
  188: has tier = 'gold' are returned (rows with no secondary entry are excluded, just
  189: as a WHERE clause would exclude them).
  190: 
  191: =head2 Column ownership and routing
  192: 
  193: At construction time, C<Database::Join> calls C<columns()> on each component
  194: database and builds an internal index that maps every column name to the
  195: database that owns it.
  196: 
  197: When you pass criteria to a query method, each key-value pair is automatically
  198: routed to the right database.  You never need to say which database a column
  199: belongs to.
  200: 
  201: The C<join_column> is special: criteria on it are broadcast to I<all>
  202: databases so that each database fetches only the relevant rows before the
  203: in-memory merge.
  204: 
  205: When the same non-join column name exists in more than one database, the
  206: I<last> database in the C<databases> array wins: its value overwrites earlier
  207: ones in merged rows.
  208: 
  209: =head1 LIMITATIONS
  210: 
  211: =over 4
  212: 
  213: =item In-memory join only
  214: 
  215: All matching rows from every component database are fetched into memory before
  216: the merge.  This is not suitable for very large result sets.
  217: 
  218: =item No chained builder or raw SQL
  219: 
  220: C<query()> and C<execute()> are not implemented.  Use C<selectall_arrayref>
  221: or C<fetchrow_hashref> instead.
  222: 
  223: =item Single-column equi-join only
  224: 
  225: Joining on more than one column simultaneously, or on expressions, is not
  226: supported.  When the join key has different names in different databases,
  227: use C<join_map> to declare each database's local column name.
  228: 
  229: =item Sort order
  230: 
  231: Results are sorted by the C<join_column> value only.  Caller-specified
  232: C<ORDER BY> is not propagated to the component databases.
  233: 
  234: =item count() fetches all rows
  235: 
  236: C<count()> executes the full join and counts the resulting rows in Perl.  It
  237: does not push a C<COUNT(*)> query down to the databases.
  238: 
  239: =back
  240: 
  241: =head1 COMMON PITFALLS
  242: 
  243: =over 4
  244: 
  245: =item The join_column must exist in every component database
  246: 
  247: If even one database is missing the join key column, C<new()> (or
  248: C<add_database()>) will C<croak> immediately.  Use C<join_map> when the
  249: column has a different local name in some databases.
  250: 
  251: =item Criteria on a removed column are silently dropped
  252: 
  253: If you call C<remove_column('tier')> and later query
  254: C<< selectall_arrayref(tier => 'gold') >>, the criterion is ignored (with a
  255: C<carp> warning) and all rows are returned.  Always pass criteria before
  256: removing columns, or restructure your code to avoid this.
  257: 
  258: =item You cannot remove the join column
  259: 
  260: C<< $join->remove_column($join->join_column) >> will C<croak>.  The join key
  261: is required for the merge to work.
  262: 
  263: =item Left join does not guarantee all columns are populated
  264: 
  265: Under a LEFT join, rows from the primary database that have no matching row
  266: in a secondary database will be returned with I<no keys> from that secondary
  267: database.  Accessing C<< $row->{score} >> on such a row returns C<undef> --
  268: not zero, not an empty string.  Always test C<defined $row->{score}> rather
  269: than just C<$row->{score}> when the secondary match is optional.
  270: 
  271: =item Filters act as inner-join partners
  272: 
  273: Any database that has a C<filters> entry is promoted to an inner-join partner,
  274: regardless of C<join_type>.  A row whose join-key value does not appear in
  275: the filtered database's result is removed from the merged output entirely, not
  276: merely missing its secondary columns.  This is intentional but can be
  277: surprising if you expected LEFT join semantics.
  278: 
  279: =item Criteria-merging replaces scalar filters
  280: 
  281: When both a base filter and a query criterion target the same column, and both
  282: are operator hashrefs (e.g. C<< { '>' => 60 } >>), the operators are combined
  283: (AND semantics).  But if the query criterion is a plain scalar (e.g.
  284: C<< score => 75 >>), it I<replaces> the base filter for that column entirely --
  285: the base filter is ignored for that query.
  286: 
  287: =item AUTOLOAD sees the full merged join when filters or join_map are active
  288: 
  289: When either C<filters> or C<join_map> is in effect, the AUTOLOAD shortcut
  290: (C<< $join->columnname(...) >>) runs the full join query rather than
  291: delegating directly to the owning database.  This is necessary for correctness
  292: but means the result respects all active filters and join-key translations,
  293: which may differ from what the owning database would return on its own.
  294: 
  295: =item Duplicate column names: last database wins
  296: 
  297: When two component databases each have a column called C<notes>, the second
  298: database's value silently overwrites the first in every merged row.  Use
  299: C<remove_columns> (or C<remove_column>) to drop the unwanted duplicate.
  300: 
  301: =back
  302: 
  303: =head1 METHODS
  304: 
  305: =head2 new
  306: 
  307: =head3 SYNOPSIS
  308: 
  309:     my $join = Database::Join->new(
  310:         databases      => [ $db1, $db2 ],
  311:         join_column    => 'entry',
  312:         join_type      => 'left',
  313:         join_map       => { 1 => 'local_col' },
  314:         filters        => { 1 => { score => { '>' => 60 } } },
  315:         remove_columns => [ 'email', 'internal_id' ],
  316:         logger         => $log,
  317:         i18n           => $locale,
  318:     );
  319: 
  320: =head3 DESCRIPTION
  321: 
  322: Constructs and returns a new C<Database::Join> object.
  323: 
  324: Each element of C<databases> must be an already-instantiated subclass of
  325: C<Database::Abstraction>.  The constructor calls C<columns()> on every
  326: database to build an internal column-routing table and verifies that
  327: C<join_column> (or its local alias from C<join_map>) is present in each one.
  328: 
  329: Columns listed in C<remove_columns> are hidden immediately: they do not appear
  330: in C<columns()>, C<schema()>, or any returned row hashref.  This is equivalent
  331: to calling C<remove_column> once per name after construction.
  332: 
  333: =head3 API SPECIFICATION
  334: 
  335: =head4 Input
  336: 
  337:     databases      => { type => 'arrayref', required => 1 }
  338:                       # One or more Database::Abstraction subclass objects.
  339:                       #
  340:                       # DOMAIN -- EP valid:   non-empty arrayref of blessed DA subclasses.
  341:                       # DOMAIN -- EP invalid: scalar, hashref, or absent => croak.
  342:                       # DOMAIN -- BVA size:   minimum 1 element; no documented upper bound.
  343:                       # DOMAIN -- BVA elem:   each element must pass isa('Database::Abstraction').
  344: 
  345:     join_column    => { type => 'string',   optional => 1, default => 'entry' }
  346:                       # The column name shared by all databases (the join key).
  347:                       #
  348:                       # DOMAIN -- EP valid:   any non-empty string present in every component DA.
  349:                       # DOMAIN -- EP invalid: column absent from any DA => croak join_col_missing.
  350:                       # DOMAIN -- BVA:        empty string '' is treated as a column name and
  351:                       #                       will croak if (as expected) it is absent from every DA.
  352:                       # DOMAIN -- NOTE:       matching is case-sensitive and exact.
  353: 
  354:     join_type      => { type => 'string',   optional => 1, default => 'left',
  355:                         enum => ['inner', 'left', 'outer'] }
  356:                       # Controls which keys appear in the result when not all
  357:                       # databases share the same key values.
  358:                       #
  359:                       # DOMAIN -- EP valid:   exactly 'inner', 'left', or 'outer'.
  360:                       # DOMAIN -- EP invalid: any other string including 'INNER', 'LEFT',
  361:                       #                       'OUTER' (enum check is case-sensitive), 'cross',
  362:                       #                       or '' => croak from validate_strict.
  363: 
  364:     join_map       => { type => 'hashref',  optional => 1 }
  365:                       # Zero-based database index => local column name.
  366:                       # See the join_map section for full details.
  367:                       #
  368:                       # DOMAIN -- EP valid:   hashref values must be plain strings.
  369:                       # DOMAIN -- EP invalid: reference value (hashref, arrayref, coderef, etc.)
  370:                       #                       => croak; the guard prevents heap-address leakage.
  371:                       # DOMAIN -- BVA:        out-of-range keys (beyond the databases array) are
  372:                       #                       silently ignored.
  373: 
  374:     filters        => { type => 'hashref',  optional => 1 }
  375:                       # Zero-based database index => criteria hashref.
  376:                       # Permanent row restrictions on individual databases.
  377:                       # See the filters section for full details.
  378: 
  379:     remove_columns => { type => 'arrayref', optional => 1 }
  380:                       # Column names to hide from the merged view.
  381:                       #
  382:                       # DOMAIN -- EP valid:   arrayref of any strings; non-existent columns
  383:                       #                       are silently ignored (idempotent).
  384:                       # DOMAIN -- EP invalid: join_column itself => croak remove_join_col.
  385:                       # DOMAIN -- BVA:        [] empty arrayref is a safe no-op.
  386: 
  387:     logger         => { type => 'object',   optional => 1 }
  388:                       # Logger object propagated to all component databases.
  389: 
  390:     i18n           => { type => 'object',   optional => 1 }
  391:                       # Localisation object with a translate($key, @args) method.
  392: 
  393: =head4 Output
  394: 
  395:     A blessed Database::Join object.
  396: 
  397: =head3 EXAMPLE
  398: 
  399:     # Customers database: entry | name | email
  400:     # Loyalty   database: entry | tier | points
  401: 
  402:     my $join = Database::Join->new(
  403:         databases      => [ $customers, $loyalty ],
  404:         join_column    => 'entry',
  405:         join_type      => 'inner',            # only customers who also have loyalty records
  406:         remove_columns => [ 'email' ],        # hide PII from query results
  407:         filters        => { 1 => { points => { '>' => 0 } } }, # ignore zero-point records
  408:     );
  409: 
  410:     my $rows = $join->selectall_arrayref();
  411:     # Each row: { entry => ..., name => ..., tier => ..., points => ... }
  412:     # 'email' is absent. Zero-point loyalty records are excluded.
  413: 
  414: =head3 PSEUDOCODE
  415: 
  416:     validate all parameters with validate_strict
  417:     croak if databases is empty
  418:     croak if any element of databases is not a Database::Abstraction subclass
  419:     bless the object with all fields initialised
  420:     call _build_col_index to map every column to its owning database
  421:         and verify join_column presence in each database
  422:     for each column in remove_columns: call remove_column
  423:     return the new object
  424: 
  425: =head3 MESSAGES
  426: 
  427:     error_no_databases     -- databases arrayref was empty
  428:     error_invalid_db       -- an element of databases is not a D::A subclass
  429:     error_join_col_missing -- join_column (or its join_map alias) not found in a database
  430: 
  431: =cut
  432: 
  433: sub new {
434 → 463 → 476  434: 	my ($class, @args) = @_;
  435: 
  436: 	my $p = validate_strict(
  437: 		schema => {
  438: 			databases	=> { type => 'arrayref' },
  439: 			join_column	=> { type => 'string',   optional => 1, default => 'entry' },
  440: 			join_type	=> { type => 'string',   optional => 1, default => 'left',
  441: 			                  enum => ['inner', 'left', 'outer'] },
  442: 			join_map	=> { type => 'hashref',  optional => 1 },
  443: 			filters		=> { type => 'hashref',  optional => 1 },
  444: 			remove_columns	=> { type => 'arrayref', optional => 1 },
  445: 			logger		=> { type => 'object',   optional => 1 },
  446: 			i18n		=> { type => 'object',   optional => 1 },
  447: 		},
  448: 		input => get_params(undef, \@args) // {},
  449: 	);
  450: 
  451: 	croak _msg($p->{i18n}, 'error_no_databases')
  452: 		unless @{ $p->{databases} };
  453: 
  454: 	# Capture caller-supplied logger and i18n object BEFORE Object::Configure::configure
  455: 	# overwrites them with class-level defaults.  configure() reads default values
  456: 	# from Database::Abstraction's class config and silently replaces any caller-
  457: 	# supplied value if a class default exists for that key.
  458: 	my $caller_logger = $p->{logger};
  459: 	my $caller_i18n   = $p->{i18n};
  460: 
  461: 	$p = Object::Configure::configure($class, $p);
  462: 
  463: 	for my $i (0 .. $#{ $p->{databases} }) {
  464: 		croak _msg($p->{i18n}, 'error_invalid_db', $i)
  465: 			unless blessed($p->{databases}[$i])
  466: 			    && $p->{databases}[$i]->isa('Database::Abstraction');
  467: 	}
  468: 
  469: 	# Cache the primary database's internal primary-key column name once at
  470: 	# construction.  AUTOLOAD uses this to map a bare positional argument to the
  471: 	# correct column when join_map is active and the primary DB's primary key
  472: 	# differs from join_column (e.g. join_col='statecode' but primary key='entry').
  473: 	# Accessing {id} here — at construction time, before the object is shared —
  474: 	# is the single permitted point of coupling to DA's internal field; caching
  475: 	# avoids repeating the hash intrusion on every AUTOLOAD call.
476 → 503 → 508  476: 	my $primary_pk = $p->{databases}[0]{id} // $p->{join_column};
  477: 
  478: 	my $self = bless {
  479: 		_dbs          => $p->{databases},
  480: 		_join_col     => $p->{join_column},
  481: 		_join_type    => $p->{join_type},
  482: 		_join_map     => $p->{join_map} // {},	# db_index => local join col name
  483: 		# TODO: Data Flow Anomaly (filter reference aliasing) - _filters stores
  484: 		# the caller's hashref directly.  External mutation of the caller's hash
  485: 		# after construction will silently change query behaviour.  A deep copy
  486: 		# (e.g. Storable::dclone) would bound the lifetime but adds a dependency.
  487: 		_filters      => $p->{filters}  // {},	# db_index => criteria hashref
  488: 		_logger       => $caller_logger,
  489: 		_i18n         => $caller_i18n,
  490: 		_col_db       => {},	# column_name => db_index
  491: 		_db_cols      => [],	# per-db column-presence hashref
  492: 		_removed_cols => {},	# column_name => 1 (hidden from the view)
  493: 		_col_cache    => undef,	# memoised columns() result
  494: 		_schema_cache => undef,	# memoised schema() result
  495: 		_autoload_pk  => $primary_pk,	# primary DB's key col; positional arg for AUTOLOAD
  496: 	}, $class;
  497: 
  498: 	$self->_build_col_index();
  499: 
  500: 	# Propagate the logger to every component database if one was supplied.
  501: 	# set_logger() is used here (rather than a direct hash write) to honour each
  502: 	# DA's own logging setup hook and remain decoupled from DA internals.
  503: 	if (my $log = $self->{_logger}) {
  504: 		$_->set_logger($log) for @{ $self->{_dbs} };
  505: 	}

Mutants (Total: 1, Killed: 1, Survived: 0)

506: 507: # Apply column removals requested in the constructor 508 → 508 → 512 508: if (my $rc = $p->{remove_columns}) { 509: $self->remove_column($_) for @{$rc}; 510: }

Mutants (Total: 1, Killed: 1, Survived: 0)

511: 512: return $self; 513: } 514:

Mutants (Total: 2, Killed: 2, Survived: 0)

515: # --------------------------------------------------------------------------- 516: # Public API (mirrors Database::Abstraction) 517: # --------------------------------------------------------------------------- 518: 519: =head2 join_map - joining on differently-named columns 520: 521: By default every component database must have a column whose name matches 522: C<join_column>. If a database uses a different local name for the join key, 523: declare the mapping with C<join_map>. 524: 525: C<join_map> is a hashref. Each B<key> is the B<zero-based position> of a 526: database in the C<databases> array (0 = first, 1 = second, and so on). Each 527: B<value> is the name that B<that particular database> uses for the join key. 528: 529: Databases not listed in C<join_map> are assumed to already have a column 530: named C<join_column> and need no entry. 531: 532: Throughout the merged view the join key is I<always> referred to by the name 533: given in C<join_column>. The local alias is never exposed in returned rows, 534: in C<columns()>, or in C<schema()>. 535: 536: B<When do you need join_map?> 537: 538: You need C<join_map> when you have two tables like: 539: 540: cities table : entry (the city name) | statecode 541: stnames table : entry (the state code) | state 542: 543: Here you want to join cities.statecode to stnames.entry. You choose 544: C<< join_column => 'statecode' >> as the canonical name, but stnames calls 545: that same concept C<entry>, so you declare: 546: 547: join_map => { 1 => 'entry' } # stnames (index 1) calls it 'entry' 548: 549: B<Example> 550: 551: # index 0 index 1 552: my @databases = ( $cities, $stnames ); 553: # join key column: 'statecode' 'entry' 554: # join_column: 'statecode' (chosen canonical name) 555: # stnames differs, so declare the alias: 556: 557: my $join = Database::Join->new( 558: databases => \@databases, 559: join_column => 'statecode', 560: join_map => { 1 => 'entry' }, 561: ); 562: 563: my $rows = $join->selectall_arrayref(); 564: # Each $row has keys: entry (city), statecode, state 565: # 'entry' from stnames is never exposed directly. 566: 567: my $row = $join->fetchrow_hashref(statecode => 'CA'); 568: 569: B<Using add_database instead> 570: 571: If you build the join incrementally with C<add_database>, pass 572: C<join_column> directly to that call instead of using C<join_map>: 573: 574: my $join = Database::Join->new( 575: databases => [ $cities ], 576: join_column => 'statecode', 577: ); 578: $join->add_database($stnames, join_column => 'entry'); 579: 580: This is exactly equivalent to the C<join_map> form above. 581: 582: =head2 filters - permanent per-database row filters 583: 584: C<filters> lets you restrict a component database to a subset of its rows 585: permanently, without repeating the criterion on every query call. 586: 587: Think of it as telling the join: "whenever you query this database, always 588: add these extra conditions". Callers never need to specify the restriction 589: themselves and can never accidentally omit it. 590: 591: C<filters> is a hashref. Each B<key> is the B<zero-based position> of a 592: database in the C<databases> array (same numbering as C<join_map>). Each 593: B<value> is a criteria hashref in the same format as C<selectall_arrayref> 594: accepts. 595: 596: B<Key-set semantics> 597: 598: A filtered database always acts as an inner-join partner, regardless of the 599: C<join_type> setting. Any join-key value that does not pass the filter is 600: excluded from the merged output entirely -- not just missing its secondary 601: columns. This ensures the filter genuinely restricts the view rather than 602: simply hiding a few fields. 603: 604: B<Criteria merging> 605: 606: When a query call also passes a criterion for a column that already has a base 607: filter, the two constraints are combined: 608: 609: =over 4 610: 611: =item * 612: 613: When both the base filter value and the query criterion are operator hashrefs 614: (e.g. C<< { '>' => 60 } >> and C<< { '<' => 365 } >>), their operators are 615: merged: I<both> constraints apply simultaneously (AND semantics). 616: 617: =item * 618: 619: When either value is a plain scalar, or the operators conflict, the 620: query-time criterion wins and the base filter for that column is ignored for 621: that one call. 622: 623: =back 624: 625: B<Example -- only show orders placed more than 60 days ago> 626: 627: my $join = Database::Join->new( 628: databases => [ $customers, $orders ], 629: join_column => 'entry', 630: filters => { 1 => { age_days => { '>' => 60 } } }, 631: ); 632: 633: # Every query automatically sees only old orders 634: my $rows = $join->selectall_arrayref(); 635: 636: # Additional criteria layer on top -- gold tier AND old order 637: my $vip = $join->selectall_arrayref(tier => 'gold'); 638: 639: # Range intersection: age_days > 60 AND age_days < 365 640: my $mid = $join->selectall_arrayref(age_days => { '<' => 365 }); 641: 642: When using C<add_database>, pass C<filter> (singular) to set the base 643: criteria for the new database: 644: 645: $join->add_database($orders, filter => { age_days => { '>' => 60 } }); 646: 647: =head2 selectall_arrayref 648: 649: =head3 SYNOPSIS 650: 651: my $rows = $join->selectall_arrayref(); 652: my $rows = $join->selectall_arrayref(tier => 'gold'); 653: my $rows = $join->selectall_arrayref(score => { '>' => 80 }); 654: my $rows = $join->selectall_arrayref('C001'); # positional: entry => 'C001' 655: 656: =head3 DESCRIPTION 657: 658: Returns an arrayref of hashrefs representing the merged view of all component 659: databases, optionally filtered by the given criteria. 660: 661: Criteria for columns that live in different databases are routed 662: automatically: each database is queried with only the criteria that apply to 663: its own columns. The results are combined in memory using C<join_column>. 664: 665: Accepts the same criteria syntax as C<Database::Abstraction::selectall_arrayref>. 666: A single plain scalar argument is interpreted as the C<join_column> value 667: (equivalent to C<< entry => 'C001' >> when C<join_column> is C<'entry'>). 668: 669: =head3 API SPECIFICATION 670: 671: =head4 Input 672: 673: Calling conventions (in order of precedence): 674: 1. No arguments -- returns all rows 675: 2. One plain scalar -- shorthand for join_column => $scalar 676: 3. Key-value pairs or 677: a criteria hashref -- routed per-database 678: 679: Values may be: 680: Plain scalar -- exact match 681: Hashref of operators -- e.g. { '>' => 80 } 682: 683: =head4 Output 684: 685: Arrayref of hashrefs; one hashref per qualifying merged row, 686: sorted ascending by join_column value. 687: Returns a reference to an empty array when no rows match. 688: 689: =head3 EXAMPLE 690: 691: # All rows from both databases 692: my $all = $join->selectall_arrayref(); 693: 694: # Only rows where the 'tier' column (from the loyalty database) 695: # equals 'gold' -- the criterion is routed to the right database 696: my $vip = $join->selectall_arrayref(tier => 'gold'); 697: 698: # Operator hashref: score > 80 699: my $high = $join->selectall_arrayref(score => { '>' => 80 }); 700: 701: # Access each merged row 702: for my $row (@{$vip}) { 703: printf "%-10s tier=%-8s score=%d\n", 704: $row->{entry}, $row->{tier}, $row->{score} // 0; 705: } 706: 707: =cut 708: 709: sub selectall_arrayref { 710: my ($self, @args) = @_; 711: return $self->_joined_query($self->_parse_query_args(undef, @args)); 712: } 713:

Mutants (Total: 2, Killed: 2, Survived: 0)

714: =head2 selectall_array 715: 716: =head3 SYNOPSIS 717: 718: my @rows = $join->selectall_array(tier => 'gold'); 719: 720: # Scalar context: only the first matching row 721: my $first = $join->selectall_array(entry => 'C001'); 722: 723: =head3 DESCRIPTION 724: 725: In list context returns a list of merged hashrefs -- the same rows that 726: C<selectall_arrayref> would return, just as a flat list rather than an 727: arrayref. 728: 729: In scalar context returns only the first matching hashref (or C<undef> if 730: nothing matches). 731: 732: =head3 API SPECIFICATION 733: 734: =head4 Input 735: 736: Same as selectall_arrayref. 737: 738: =head4 Output 739: 740: List context: list of hashrefs (may be empty). 741: Scalar context: single hashref or undef. 742: 743: =head3 EXAMPLE 744: 745: my @all = $join->selectall_array(); 746: print scalar @all, " rows\n"; 747: 748: # First gold-tier customer only 749: my $first_vip = $join->selectall_array(tier => 'gold'); 750: print $first_vip->{name}, "\n" if defined $first_vip; 751: 752: =cut 753: 754: sub selectall_array { 755: my ($self, @args) = @_; 756: my $rows = $self->_joined_query($self->_parse_query_args(undef, @args)); 757: return wantarray ? @{$rows} : $rows->[0]; 758: } 759:

Mutants (Total: 2, Killed: 2, Survived: 0)

760: =head2 fetchrow_hashref 761: 762: =head3 SYNOPSIS 763: 764: my $row = $join->fetchrow_hashref(entry => 'C001'); 765: my $row = $join->fetchrow_hashref('C001'); # positional shorthand 766: 767: =head3 DESCRIPTION 768: 769: Returns a single merged hashref for the first row matching the given 770: criteria, or C<undef> when nothing matches. 771: 772: Equivalent to calling C<selectall_arrayref> and taking only the first element. 773: All the same criteria conventions apply. 774: 775: =head3 API SPECIFICATION 776: 777: =head4 Input 778: 779: Same as selectall_arrayref. 780: 781: =head4 Output 782: 783: Hashref, or undef when no row matches. 784: 785: =head3 EXAMPLE 786: 787: my $row = $join->fetchrow_hashref(entry => 'C001'); 788: if (defined $row) { 789: print "Name: $row->{name}, Tier: $row->{tier}\n"; 790: } else { 791: print "No record for C001\n"; 792: } 793: 794: # Positional: works when join_column is 'entry' 795: my $row2 = $join->fetchrow_hashref('C001'); 796: 797: =cut 798: 799: sub fetchrow_hashref { 800: my ($self, @args) = @_; 801: my $rows = $self->_joined_query($self->_parse_query_args(undef, @args)); 802: return $rows->[0]; 803: } 804:

Mutants (Total: 2, Killed: 2, Survived: 0)

805: =head2 count 806: 807: =head3 SYNOPSIS 808: 809: my $total = $join->count(); 810: my $active = $join->count(tier => 'gold'); 811: 812: =head3 DESCRIPTION 813: 814: Returns the number of merged rows that satisfy the given criteria. 815: 816: The full join is performed and the resulting rows are counted in Perl; no 817: C<COUNT(*)> is pushed down to the component databases. 818: 819: =head3 API SPECIFICATION 820: 821: =head4 Input 822: 823: Same criteria syntax as selectall_arrayref. 824: 825: =head4 Output 826: 827: Non-negative integer. 828: 829: =head3 EXAMPLE 830: 831: my $total = $join->count(); 832: my $gold = $join->count(tier => 'gold'); 833: my $high = $join->count(score => { '>' => 90 }); 834: 835: printf "%d total, %d gold-tier, %d high-scorers\n", 836: $total, $gold, $high; 837: 838: =cut 839: 840: sub count { 841: my ($self, @args) = @_; 842: my $rows = $self->_joined_query($self->_parse_query_args(undef, @args)); 843: return scalar @{$rows}; 844: } 845:

Mutants (Total: 2, Killed: 2, Survived: 0)

846: =head2 columns 847: 848: =head3 SYNOPSIS 849: 850: my $cols = $join->columns(); 851: 852: =head3 DESCRIPTION 853: 854: Returns an arrayref of all column names visible in the merged view, 855: deduplicated and sorted alphabetically. 856: 857: The C<join_column> appears exactly once, even if it exists under different 858: local names in some databases (see C<join_map>). Columns that have been 859: hidden with C<remove_column> or C<remove_columns> do not appear. 860: 861: The result is memoised: repeated calls are cheap. 862: 863: =head3 API SPECIFICATION 864: 865: =head4 Input 866: 867: None. 868: 869: =head4 Output 870: 871: Arrayref of column name strings, sorted alphabetically. 872: 873: =head3 EXAMPLE 874: 875: my $cols = $join->columns(); 876: print join(', ', @{$cols}), "\n"; 877: # e.g. "entry, name, score, tier" 878: 879: =cut 880: 881: sub columns { 882 → 890 → 902 882: my ($self) = @_; 883: 884: return $self->{_col_cache} if $self->{_col_cache}; 885: 886: my %seen;

Mutants (Total: 2, Killed: 2, Survived: 0)

887: my @cols; 888: my $join_col = $self->{_join_col}; 889: 890: for my $i (0 .. $#{ $self->{_dbs} }) { 891: my $local_jc = $self->{_join_map}{$i}; 892: for my $col (@{ $self->{_dbs}[$i]->columns() }) { 893: next if $seen{$col}++; 894: next if $self->{_removed_cols}{$col}; 895: # The local join-key alias is not a data column; the canonical name 896: # is already contributed by the database that owns it under that name. 897: next if $local_jc && $col eq $local_jc && $col ne $join_col; 898: push @cols, $col; 899: } 900: } 901: 902: $self->{_col_cache} = [ sort @cols ]; 903: return $self->{_col_cache}; 904: } 905:

Mutants (Total: 2, Killed: 2, Survived: 0)

906: =head2 schema 907: 908: =head3 SYNOPSIS 909: 910: my $schema = $join->schema(); 911: 912: =head3 DESCRIPTION 913: 914: Returns a merged schema hashref for all visible columns across all component 915: databases. Each key is a column name; each value is the schema metadata 916: hashref returned by C<Database::Abstraction::schema()> for that column 917: (typically C<{ type, nullable, default, pk }>). 918: 919: When the same column name appears in more than one database the I<last> 920: database's metadata is used. Columns hidden with C<remove_column> are not 921: included. 922: 923: The result is memoised. 924: 925: =head3 API SPECIFICATION 926: 927: =head4 Input 928: 929: None. 930: 931: =head4 Output 932: 933: Hashref: column_name => { type => ..., nullable => ..., default => ..., pk => ... }. 934: 935: =head3 EXAMPLE 936: 937: my $schema = $join->schema(); 938: for my $col (sort keys %{$schema}) { 939: my $info = $schema->{$col}; 940: printf "%-15s type=%-10s nullable=%s\n", 941: $col, $info->{type}, $info->{nullable} ? 'yes' : 'no'; 942: } 943: 944: =cut 945: 946: sub schema { 947 → 955 → 967 947: my ($self) = @_; 948: 949: return $self->{_schema_cache} if $self->{_schema_cache}; 950: 951: # Hash slice assignment (@merged{keys} = values) is O(M) per database;

Mutants (Total: 2, Killed: 2, Survived: 0)

952: # the previous (%merged = (%merged, %s)) pattern was O(N×M) per iteration, 953: # totalling O(N²×M) over all databases for the same result. 954: my %merged; 955: for my $i (0 .. $#{ $self->{_dbs} }) { 956: my $s = $self->{_dbs}[$i]->schema() // {}; 957: my $local_jc = $self->{_join_map}{$i}; 958: if ($local_jc && $local_jc ne $self->{_join_col}) { 959: for my $col (keys %{$s}) { 960: $merged{$col} = $s->{$col} unless $col eq $local_jc;

Mutants (Total: 1, Killed: 1, Survived: 0)

961: } 962: } else { 963: @merged{keys %{$s}} = values %{$s}; 964: } 965: } 966: 967: delete @merged{keys %{ $self->{_removed_cols} }} 968: if %{ $self->{_removed_cols} }; 969: 970: $self->{_schema_cache} = \%merged; 971: return $self->{_schema_cache}; 972: } 973:

Mutants (Total: 2, Killed: 2, Survived: 0)

974: =head2 updated 975: 976: =head3 SYNOPSIS 977: 978: my $ts = $join->updated(); 979: 980: =head3 DESCRIPTION 981: 982: Returns the Unix timestamp of the most recent modification across all 983: component databases. This is the maximum of all individual C<updated()> 984: return values. 985: 986: Use this to implement simple cache-invalidation logic: if C<updated()> 987: has advanced since your last snapshot, re-query. 988: 989: =head3 API SPECIFICATION 990: 991: =head4 Input 992: 993: None. 994: 995: =head4 Output 996: 997: Unix timestamp (positive integer). 998: 999: =head3 EXAMPLE 1000: 1001: my $last_modified = $join->updated(); 1002: if ($last_modified > $my_cache_timestamp) { 1003: $my_cache = $join->selectall_arrayref(); 1004: $my_cache_timestamp = $last_modified; 1005: } 1006: 1007: =cut 1008: 1009: sub updated { 1010: my ($self) = @_; 1011: return max(map { $_->updated() } @{ $self->{_dbs} }); 1012: } 1013:

Mutants (Total: 2, Killed: 2, Survived: 0)

1014: =head2 set_logger 1015: 1016: =head3 SYNOPSIS 1017: 1018: $join->set_logger($log); 1019: 1020: =head3 DESCRIPTION 1021: 1022: Attaches a new logger object to the join and propagates it to every component 1023: database. The logger is used for diagnostic output by all component databases. 1024: 1025: =head3 API SPECIFICATION 1026: 1027: =head4 Input 1028: 1029: $log Positional: a logger object (required). 1030: Must support whatever interface Database::Abstraction expects. 1031: 1032: =head4 Output 1033: 1034: Returns C<$self> for method chaining. 1035: 1036: =head3 EXAMPLE 1037: 1038: # Log::Any is used here as an example; any object that implements 1039: # debug() and info() (or whichever methods your component databases 1040: # call internally) works equally well. 1041: use Log::Any qw($log); 1042: 1043: my $join = Database::Join->new(databases => [$db1, $db2], join_column => 'entry'); 1044: $join->set_logger($log); 1045: # $log is now used by $join and by $db1 and $db2 1046: 1047: =cut 1048: 1049: sub set_logger { 1050: my ($self, $logger) = @_; 1051: 1052: croak 'Usage: set_logger($logger)' unless defined $logger; 1053: 1054: $self->{_logger} = $logger; 1055: $_->set_logger($logger) for @{ $self->{_dbs} }; 1056:

Mutants (Total: 2, Killed: 2, Survived: 0)

1057: return $self; 1058: } 1059: 1060: =head2 add_database 1061: 1062: =head3 SYNOPSIS 1063: 1064: # Positional: database object as first argument 1065: $join->add_database($db); 1066: 1067: # Named: equivalent to the above 1068: $join->add_database(database => $db); 1069: 1070: # With options (mixed positional + named) 1071: $join->add_database($db, remove_columns => ['internal_id']); 1072: $join->add_database($db, join_column => 'local_key_name'); 1073: $join->add_database($db, filter => { score => { '>' => 60 } }); 1074: 1075: # Chainable 1076: $join->add_database($db1)->add_database($db2, remove_columns => ['notes']); 1077: 1078: =head3 DESCRIPTION 1079: 1080: Adds one more C<Database::Abstraction> subclass object to the logical view 1081: and immediately updates the column-ownership index. 1082: 1083: After the call, all query methods return rows that include columns from the 1084: newly added database, and criteria on those new columns are routed to it 1085: automatically. 1086: 1087: When a column name in the new database already exists in an earlier database, 1088: the new database becomes the authoritative source for that column 1089: (last-database-wins, the same rule that applies at construction time). 1090: 1091: The join-column must be present in the new database (or declared via 1092: C<join_column>). The logger is propagated to the new database if one is set. 1093: 1094: C<add_database> is the runtime equivalent of listing the database in the 1095: C<databases> array to C<new>. The optional C<join_column> parameter is 1096: equivalent to a C<join_map> entry; the optional C<filter> parameter is 1097: equivalent to a C<filters> entry. 1098: 1099: =head3 API SPECIFICATION 1100: 1101: =head4 Input 1102: 1103: database => { type => 'object', required => 1 } 1104: # A Database::Abstraction subclass instance. 1105: # 1106: # DOMAIN -- EP valid: blessed object that passes 1107: # isa('Database::Abstraction'). 1108: # DOMAIN -- EP invalid: non-reference, unblessed ref, wrong class, 1109: # or non-reference non-key scalar (the guard at 1110: # the top of add_database rejects it with 1111: # error_invalid_db before validate_strict runs). 1112: 1113: join_column => { type => 'string', optional => 1 } 1114: # The name of the join key in THIS new database, 1115: # when it differs from the canonical join_column. 1116: # 1117: # DOMAIN -- EP valid: any string that exists as a column in the 1118: # new database. 1119: # DOMAIN -- EP invalid: string absent from the new database's columns() 1120: # => croak error_join_col_missing. 1121: 1122: filter => { type => 'hashref', optional => 1 } 1123: # Permanent criteria for this database only. 1124: # Same format as selectall_arrayref. 1125: 1126: remove_columns => { type => 'arrayref', optional => 1 } 1127: # Column names from this database to hide. 1128: 1129: =head4 Output 1130: 1131: Returns C<$self> to support method chaining. 1132: 1133: =head3 EXAMPLE 1134: 1135: my $join = Database::Join->new( 1136: databases => [ $customers ], 1137: join_column => 'entry', 1138: ); 1139: 1140: # Add loyalty data; hide internal columns from it 1141: $join->add_database($loyalty, remove_columns => ['audit_ts']); 1142: 1143: # Add score data; only include rows with score > 60 1144: $join->add_database($scores, filter => { score => { '>' => 60 } }); 1145: 1146: # Add a database whose join key has a different local name 1147: $join->add_database($stnames, join_column => 'state_code'); 1148: 1149: # All three options combined, and chained 1150: $join->add_database($db4, 1151: join_column => 'ref_id', 1152: filter => { active => 1 }, 1153: remove_columns => ['legacy_col'], 1154: ); 1155: 1156: =head3 PSEUDOCODE 1157: 1158: determine the new database's index (length of current _dbs array) 1159: extract the database object from positional or named argument 1160: croak if it is not a Database::Abstraction subclass 1161: register join_column alias in _join_map if different from canonical 1162: register filter in _filters if provided 1163: fetch column list from the new database 1164: croak if the join key is missing from the new database 1165: append the new database to _dbs and _db_cols 1166: update _col_db: for each new column, point it at the new index 1167: (last-database-wins; skip removed columns and the local join alias) 1168: invalidate _col_cache and _schema_cache 1169: propagate logger if set 1170: apply remove_columns if provided 1171: return $self 1172: 1173: =head3 MESSAGES 1174: 1175: error_invalid_db -- argument is not a Database::Abstraction subclass 1176: error_join_col_missing -- join_column not found in the new database 1177: 1178: =cut 1179: 1180: sub add_database { 1181 → 1190 → 1199 1181: my ($self, @args) = @_; 1182: 1183: my $idx = scalar @{ $self->{_dbs} }; 1184: my $db; 1185: 1186: # Fail-fast guard: a non-reference first arg must be a recognised named-pair key. 1187: # Modus Ponens: !ref(x) ∧ x ∉ @_ADD_DB_KEYS → cannot be a database object → croak. 1188: # De Morgan reduction: the elsif below is logically equivalent to (@args && ref(args[0])) 1189: # because the !ref branch was already handled; exhaustion makes the ref() check redundant.

Mutants (Total: 1, Killed: 1, Survived: 0)

1190: if (@args && !ref($args[0])) { 1191: croak $self->_err('error_invalid_db', $idx) 1192: unless grep { $args[0] eq $_ } @_ADD_DB_KEYS; 1193: } elsif (@args) { 1194: # Positional form: first arg is a reference — extract it before get_params 1195: # to avoid the mixed positional+named-pairs confusion. 1196: $db = shift @args; 1197: } 1198: 1199 → 1231 → 1238 1199: my $p = validate_strict( 1200: schema => { 1201: database => { type => 'object', optional => 1 }, 1202: join_column => { type => 'string', optional => 1 }, 1203: filter => { type => 'hashref', optional => 1 }, 1204: remove_columns => { type => 'arrayref', optional => 1 }, 1205: }, 1206: input => (@args ? get_params(undef, @args) : {}) // {}, 1207: ); 1208: 1209: $db //= $p->{database}; 1210: 1211: croak $self->_err('error_invalid_db', $idx) 1212: unless blessed($db) && $db->isa('Database::Abstraction'); 1213: 1214: # Determine and register the local join column name for this database 1215: my $local_jc = $p->{join_column} // $self->{_join_col}; 1216: $self->{_join_map}{$idx} = $local_jc if $p->{join_column}; 1217: $self->{_filters}{$idx} = $p->{filter} if $p->{filter}; 1218: 1219: my $cols = $db->columns(); 1220: my %col_presence = map { $_ => 1 } @{$cols}; 1221: 1222: croak $self->_err('error_join_col_missing', $local_jc, $idx, ref($db)) 1223: unless $col_presence{$local_jc}; 1224: 1225: # Register the new database 1226: push @{ $self->{_dbs} }, $db; 1227: push @{ $self->{_db_cols} }, \%col_presence; 1228: 1229: # Update column routing: last-database-wins for duplicate column names; 1230: # skip the local join-key alias (it is not a data column). 1231: for my $col (@{$cols}) { 1232: next if $self->{_removed_cols}{$col}; 1233: next if $local_jc ne $self->{_join_col} && $col eq $local_jc; 1234: $self->{_col_db}{$col} = $idx; 1235: } 1236: 1237: # Invalidate memoisation caches 1238 → 1242 → 1247 1238: $self->{_col_cache} = undef; 1239: $self->{_schema_cache} = undef; 1240: 1241: # Propagate logger if one is configured

Mutants (Total: 1, Killed: 1, Survived: 0)

1242: if (my $log = $self->{_logger}) { 1243: $db->set_logger($log); 1244: } 1245: 1246: # Apply any column removals requested for this database

Mutants (Total: 1, Killed: 1, Survived: 0)

1247 → 1247 → 1251 1247: if (my $rc = $p->{remove_columns}) { 1248: $self->remove_column($_) for @{$rc}; 1249: } 1250:

Mutants (Total: 2, Killed: 2, Survived: 0)

1251: return $self; 1252: } 1253: 1254: =head2 remove_column 1255: 1256: =head3 SYNOPSIS 1257: 1258: $join->remove_column('email'); 1259: 1260: # Chainable 1261: $join->remove_column('internal_id')->remove_column('audit_ts'); 1262: 1263: =head3 DESCRIPTION 1264: 1265: Permanently hides a column from the merged view. After this call: 1266: 1267: =over 4 1268: 1269: =item * 1270: 1271: The column does not appear in C<columns()> or C<schema()>. 1272: 1273: =item * 1274: 1275: Returned row hashrefs do not contain the column key. 1276: 1277: =item * 1278: 1279: Any query criterion that references the removed column is silently dropped 1280: (with a C<carp> warning). 1281: 1282: =back 1283: 1284: The C<join_column> cannot be removed; attempting to do so will C<croak>. 1285: Removing a column that does not exist in any database is silently ignored 1286: (the call is idempotent and safe). The C<columns()> and C<schema()> 1287: memoisation caches are cleared automatically. 1288: 1289: =head3 API SPECIFICATION 1290: 1291: =head4 Input 1292: 1293: $col Positional string: the column name to remove. 1294: 1295: DOMAIN -- EP valid: any string; non-existent columns are silently 1296: ignored (idempotent call, returns $self). 1297: DOMAIN -- EP invalid: join_column value => croak error_remove_join_col. 1298: DOMAIN -- BVA: undef and '' are explicit no-ops (returns $self). 1299: These are below the minimum meaningful string 1300: length and are handled without any warning. 1301: 1302: =head4 Output 1303: 1304: Returns C<$self> to support method chaining. 1305: 1306: =head3 EXAMPLE 1307: 1308: # Hide private fields immediately after construction 1309: my $join = Database::Join->new( 1310: databases => [ $customers, $loyalty ], 1311: join_column => 'entry', 1312: )->remove_column('email') 1313: ->remove_column('internal_notes'); 1314: 1315: # Verify they are gone 1316: my $cols = $join->columns(); 1317: # 'email' and 'internal_notes' are absent 1318: 1319: =head3 MESSAGES 1320: 1321: error_remove_join_col -- attempt to remove the join_column itself 1322: 1323: =cut 1324: 1325: sub remove_column { 1326 → 1331 → 1338 1326: my ($self, $col) = @_; 1327: 1328: croak $self->_err('error_remove_join_col', $col) 1329: if defined $col && $col eq $self->{_join_col}; 1330:

Mutants (Total: 1, Killed: 1, Survived: 0)

1331: if (defined $col && length $col) { 1332: $self->{_removed_cols}{$col} = 1; 1333: delete $self->{_col_db}{$col}; 1334: $self->{_col_cache} = undef; 1335: $self->{_schema_cache} = undef; 1336: } 1337:

Mutants (Total: 2, Killed: 2, Survived: 0)

1338: return $self; 1339: } 1340: 1341: =head2 query 1342: 1343: Not supported. C<Database::Join> does not implement the chained query 1344: builder. Calling this method will always C<croak> with an explanatory message. 1345: 1346: Use C<selectall_arrayref>, C<selectall_array>, C<fetchrow_hashref>, or 1347: C<count> instead. 1348: 1349: =cut 1350: 1351: sub query { 1352: my ($self) = @_; 1353: croak $self->_err('error_query_unsupported'); 1354: } 1355: 1356: =head2 execute 1357: 1358: Not supported. Raw SQL cannot span heterogeneous backends that may use 1359: different database engines. Calling this method will always C<croak>. 1360: 1361: Use C<selectall_arrayref> or C<fetchrow_hashref> to query the joined view. 1362: 1363: =cut 1364: 1365: sub execute { 1366: my ($self) = @_; 1367: croak $self->_err('error_execute_unsupported'); 1368: } 1369: 1370: =head2 AUTOLOAD - column shortcut 1371: 1372: Calling an unknown method whose name matches a visible column name performs 1373: a column lookup across the merged view. 1374: 1375: =head3 SYNOPSIS 1376: 1377: # Scalar context: value from the first matching row 1378: my $name = $join->name(entry => 'C001'); 1379: 1380: # List context: values from every matching row 1381: my @tiers = $join->tier(); 1382: 1383: # With a positional join-key argument (when join_column is 'entry') 1384: my $score = $join->score('C001'); 1385: 1386: =head3 DESCRIPTION 1387: 1388: AUTOLOAD routes the call to the appropriate component database by looking up 1389: the column name in the internal column-ownership index. 1390: 1391: When either C<join_map> or C<filters> is active, AUTOLOAD performs a full 1392: join query instead of delegating directly to the owning database. This is 1393: necessary because: 1394: 1395: =over 4 1396: 1397: =item * 1398: 1399: With C<join_map>, the owning database's primary key may differ from the 1400: canonical join key used in the call arguments. 1401: 1402: =item * 1403: 1404: With C<filters>, bypassing the join would return rows that the filter is 1405: meant to exclude. 1406: 1407: =back 1408: 1409: In list context, every matching merged row contributes one value to the 1410: returned list. In scalar context, only the first row's value is returned. 1411: 1412: Calling a method whose name begins with C<_> (a private method) via AUTOLOAD 1413: will C<croak> with a clear error message rather than being silently ignored. 1414: 1415: =head3 EXAMPLE 1416: 1417: # Lookup a single customer's name (scalar context) 1418: my $name = $join->name('C001'); # 'C001' maps to entry => 'C001' 1419: print "Name: $name\n"; 1420: 1421: # Get every tier value in the view (list context) 1422: my @all_tiers = $join->tier(); 1423: my %freq; 1424: $freq{$_}++ for @all_tiers; 1425: 1426: # join_map active: AUTOLOAD runs a full join so the criteria are 1427: # translated correctly between the canonical and local key names. 1428: my @leesburg_states = sort $join->state('Leesburg'); 1429: # ['Florida', 'Virginia'] if Leesburg appears in two states 1430: 1431: =head3 PSEUDOCODE 1432: 1433: extract column name from $AUTOLOAD 1434: return if DESTROY 1435: croak if column name starts with '_' (private method guard) 1436: croak if column name is not in _col_db (unknown column) 1437: if join_map or filters are active: 1438: parse calling arguments using _parse_query_args 1439: call _joined_query to get all merged rows 1440: return map { $_->{col} } @rows in list context 1441: return $rows[0]{col} in scalar context 1442: else: 1443: delegate directly to the owning database 1444: 1445: =cut 1446: 1447: our $AUTOLOAD; 1448: 1449: sub AUTOLOAD { 1450 → 1469 → 1480 1450: my $self = shift; 1451: 1452: my ($col) = $AUTOLOAD =~ /::(\w+)$/; 1453: # TODO: Unreachable code detected during path analysis. Investigate for removal. 1454: # `sub DESTROY {}` is defined explicitly in this package; Perl's method-resolution 1455: # order finds it before AUTOLOAD is ever invoked, so $col can never equal 'DESTROY'. 1456: return if $col eq 'DESTROY'; 1457: 1458: # Private methods must not be reached via AUTOLOAD — croak immediately so 1459: # typos like $join->_join_col are not silently swallowed. 1460: croak ref($self), ": cannot call private method '$col' via AUTOLOAD" 1461: if $col =~ /^_/; 1462: 1463: my $db_idx = $self->{_col_db}{$col}; 1464: croak ref($self), ": unknown column '$col'" unless defined $db_idx; 1465: 1466: # Use a full join query when join_map OR filters are active. Direct 1467: # delegation to the owning database would bypass the join key translation 1468: # (join_map) and skip any permanent per-database row filters (filters).

Mutants (Total: 1, Killed: 1, Survived: 0)

1469: if (%{ $self->{_join_map} } || %{ $self->{_filters} }) { 1470: # _autoload_pk was captured once at construction from the primary DA's 1471: # {id} field; using the cached value avoids re-introspecting the blessed 1472: # hash on every call and isolates the coupling to a single known site. 1473: my $params = $self->_parse_query_args($self->{_autoload_pk}, @_); 1474: my $rows = $self->_joined_query($params);

Mutants (Total: 2, Killed: 2, Survived: 0)

1475: return map { $_->{$col} } @{$rows} if wantarray;

Mutants (Total: 2, Killed: 2, Survived: 0)

1476: return @{$rows} ? $rows->[0]{$col} : undef; 1477: } 1478: 1479: # $db is resolved here (not earlier) to avoid a dead store on the join path above. 1480: my $db = $self->{_dbs}[$db_idx];

Mutants (Total: 2, Killed: 2, Survived: 0)

1481: return $db->$col(@_); 1482: } 1483: 1484: sub DESTROY {} 1485: 1486: # --------------------------------------------------------------------------- 1487: # Private helpers 1488: # --------------------------------------------------------------------------- 1489: 1490: # _parse_query_args( $self, $positional_key, @caller_args ) -> \%params 1491: # Purpose: Normalise the three calling conventions used by every public query 1492: # method and AUTOLOAD into a single criteria hashref. 1493: # Entry: $positional_key -- the column name mapped to a bare scalar argument; 1494: # pass undef to use the join_column (the default for public methods). 1495: # Exit: Always returns a hashref; never undef. 1496: sub _parse_query_args :Protected { 1497: my ($self, $key, @args) = @_; 1498: $key //= $self->{_join_col}; 1499: return {} unless @args;

Mutants (Total: 1, Killed: 1, Survived: 0)

1500: return { $key => $args[0] } if @args == 1 && !ref($args[0]);

Mutants (Total: 2, Killed: 2, Survived: 0)

1501: return get_params(undef, @args) // {}; 1502: } 1503: 1504: # _err( $self, $msg_key, @sprintf_args ) -> $string 1505: # Convenience wrapper around _msg for use after construction, so callers do 1506: # not have to extract $self->{_i18n} at every error site. 1507: sub _err :Protected { 1508: my ($self, $key, @args) = @_;

Mutants (Total: 2, Killed: 2, Survived: 0)

1509: return _msg($self->{_i18n}, $key, @args); 1510: } 1511: 1512: # _build_col_index() 1513: # Purpose: Populate _col_db (column_name => db_index) and _db_cols 1514: # (per-db column-presence hashrefs) by calling columns() on each 1515: # component database at construction time. 1516: # Entry: _dbs, _join_col, _join_map must already be set. 1517: # Exit: _col_db and _db_cols are set; join_column verified in every db. 1518: # Effects: Croaks if any database is missing its join key column. 1519: sub _build_col_index :Protected { 1520 → 1526 → 1549 1520: my ($self) = @_; 1521: 1522: my $join_col = $self->{_join_col}; 1523: my %col_db; 1524: my @db_cols; 1525: 1526: for my $i (0 .. $#{ $self->{_dbs} }) { 1527: my $db = $self->{_dbs}[$i]; 1528: my $local_jc = $self->{_join_map}{$i} // $join_col; 1529: my $cols = $db->columns(); 1530: $db_cols[$i] = { map { $_ => 1 } @{$cols} }; 1531: 1532: # Guard: a join_map value that is a reference (e.g. a hashref) would 1533: # stringify to "HASH(0x...)" when interpolated into an error message, 1534: # leaking a heap address to callers. Reject early with a clear message. 1535: croak $self->_err('error_join_col_missing', "(join_map[$i] must be a string)", $i, ref($db)) 1536: if ref $local_jc; 1537: 1538: croak $self->_err('error_join_col_missing', $local_jc, $i, ref($db)) 1539: unless $db_cols[$i]{$local_jc}; 1540: 1541: for my $col (@{$cols}) { 1542: # Skip the local alias for the join key — it is not a data column 1543: next if $local_jc ne $join_col && $col eq $local_jc; 1544: # Last database wins for duplicate non-join columns 1545: $col_db{$col} = $i; 1546: } 1547: } 1548: 1549: $self->{_col_db} = \%col_db; 1550: $self->{_db_cols} = \@db_cols; 1551: 1552: return; 1553: } 1554: 1555: # _partition_criteria( \%params ) -> \@per_db 1556: # Purpose: Split a flat criteria hashref into one slice per component database. 1557: # Entry: $params is a criteria hashref; all keys must be column names or 1558: # join_column. 1559: # Exit: Returns an arrayref of per-database criteria hashrefs. The 1560: # join_column criterion is broadcast to every database using each 1561: # database's local join-key name. Unknown columns trigger a carp. 1562: # Effects: Carps for each unrecognised column name. 1563: sub _partition_criteria :Protected { 1564 → 1570 → 1587 1564: my ($self, $params) = @_; 1565: 1566: my $join_col = $self->{_join_col}; 1567: my $n = scalar @{ $self->{_dbs} }; 1568: my @per_db = map { {} } 1 .. $n; 1569: 1570: for my $col (keys %{$params}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1571: if ($col eq $join_col) { 1572: # Broadcast to every database using each one's local key column name. 1573: # Shallow-copy operator hashrefs so a malicious component DA that 1574: # mutates its criteria hashref contents cannot corrupt siblings. 1575: my $val = $params->{$col}; 1576: for my $i (0 .. $n - 1) { 1577: my $local = $self->{_join_map}{$i} // $join_col; 1578: $per_db[$i]{$local} = ref($val) eq 'HASH' ? { %{$val} } : $val; 1579: } 1580: } elsif (defined(my $idx = $self->{_col_db}{$col})) { 1581: $per_db[$idx]{$col} = $params->{$col}; 1582: } else { 1583: carp $self->_err('warn_unknown_column', $col); 1584: } 1585: } 1586:

Mutants (Total: 2, Killed: 2, Survived: 0)

1587: return \@per_db; 1588: } 1589: 1590: # _fetch_indexed( $db_idx, \%criteria ) -> \%join_val_to_\@rows 1591: # Purpose: Query one component database and index its rows by join-key value. 1592: # Entry: $db_idx is the zero-based database index; $criteria is the 1593: # pre-partitioned criteria hashref for this database. 1594: # Exit: Returns a hashref: join-key value => arrayref of row hashrefs. 1595: # Multiple rows sharing the same join-key value are all preserved 1596: # (important for the primary database when one key maps to many rows). 1597: # Effects: Calls selectall_arrayref on the component database. 1598: sub _fetch_indexed :Protected { 1599 → 1608 → 1614 1599: my ($self, $db_idx, $criteria) = @_; 1600: 1601: my $db = $self->{_dbs}[$db_idx]; 1602: my $local_jc = $self->{_join_map}{$db_idx} // $self->{_join_col}; 1603: 1604: my $rows = $db->selectall_arrayref($criteria); 1605: $rows //= []; 1606: 1607: my %indexed; 1608: for my $row (@{$rows}) { 1609: my $key = $row->{$local_jc}; 1610: next unless defined $key; 1611: push @{ $indexed{$key} }, $row; 1612: } 1613:

Mutants (Total: 2, Killed: 2, Survived: 0)

1614: return \%indexed; 1615: } 1616: 1617: # _joined_query( \%params ) -> \@merged_rows 1618: # 1619: # Purpose: Core join algorithm. Partitions criteria, fetches per-database 1620: # results, resolves the key set, and merges rows. 1621: # 1622: # Key-set resolution (applied for each secondary database after the primary): 1623: # 1624: # If the database had criteria in this query call (after base filter overlay), 1625: # it acts as an INNER-JOIN partner: only keys present in its filtered result 1626: # survive. This gives WHERE-clause semantics even under a LEFT join. 1627: # 1628: # If the database had NO effective criteria: 1629: # inner -> intersect (standard inner join) 1630: # left -> no change (primary defines the key set) 1631: # outer -> union (all keys from any database) 1632: # 1633: # Row merge: for each qualifying primary row, secondary rows are overlaid in 1634: # index order. For duplicate columns, later databases win. Local join-key 1635: # aliases are renamed to the canonical join_column before merging. 1636: # Removed columns are deleted from every merged row. 1637: sub _joined_query :Protected { 1638 → 1649 → 1657 1638: my ($self, $params) = @_; 1639: 1640: my $join_col = $self->{_join_col}; 1641: my $join_type = $self->{_join_type}; 1642: my $n = scalar @{ $self->{_dbs} }; 1643: 1644: my $per_db = $self->_partition_criteria($params); 1645: 1646: # Overlay any per-database base filters onto the partitioned criteria. 1647: # A filtered database always has effective criteria, so $had_criteria will 1648: # be true for it — giving inner-join key-set semantics regardless of join_type. 1649: for my $i (0 .. $n - 1) { 1650: my $base = $self->{_filters}{$i} // {}; 1651: next unless %{$base}; 1652: $per_db->[$i] = _merge_criteria($base, $per_db->[$i]); 1653: } 1654: 1655: # Fetch and index each database with its own criteria slice. 1656: # !!%hash collapses to 1 (non-empty) or '' (empty) without allocating a count. 1657 → 1659 → 1669 1657: my @indexed; 1658: my @had_criteria; 1659: for my $i (0 .. $n - 1) { 1660: $indexed[$i] = $self->_fetch_indexed($i, $per_db->[$i]); 1661: # TODO: Data Flow Anomaly (D~) - $had_criteria[0] written here but never read; 1662: # the key-set resolution loop below starts at i=1. When n==1 this is always 1663: # a dead store. Harmless but could be removed if n>1 is enforced, or the 1664: # loop could start at i=0 if primary-criteria semantics are ever needed. 1665: $had_criteria[$i] = !!%{ $per_db->[$i] }; 1666: } 1667: 1668: # Seed the key set from the primary database. 1669 → 1675 → 1694 1669: my %key_set = map { $_ => 1 } keys %{ $indexed[0] }; 1670: 1671: # Merge in each secondary database. 1672: # Premise 1: indexed[$i] is a valid hashref (returned by _fetch_indexed). 1673: # Premise 2: join_type ∈ {left, inner, outer} (enforced by validate_strict). 1674: # Conclusion: the three branches below are exhaustive and mutually exclusive. 1675: for my $i (1 .. $n - 1) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1676: if ($had_criteria[$i] || $join_type eq 'inner') { 1677: # Intersect: single-pass delete for keys absent from this secondary. 1678: # A single loop avoids the intermediate list that grep would allocate 1679: # before the delete loop could iterate it (saves O(K) allocations). 1680: for my $k (keys %key_set) { 1681: delete $key_set{$k} unless exists $indexed[$i]{$k}; 1682: } 1683: } elsif ($join_type eq 'outer') { 1684: # Union: hash slice assignment is a single Perl op, not a per-key loop. 1685: @key_set{ keys %{ $indexed[$i] } } = (); 1686: } 1687: # left + no criteria: key_set unchanged (primary defines the set). 1688: } 1689: 1690: # Build one merged result row for every primary-database row that qualifies. 1691: # Secondary databases act as lookup tables: when a key maps to multiple 1692: # secondary rows, the last one wins (consistent with construction-time 1693: # last-database-wins column routing). 1694 → 1696 → 1732 1694: my @result; 1695: my @removed = keys %{ $self->{_removed_cols} }; 1696: for my $key (sort keys %key_set) { 1697: # All qualifying rows from the primary database for this key. 1698: # Use [{}] so that outer-join keys absent from the primary still 1699: # produce one merged row filled from secondary databases. 1700: my @base_rows = @{ $indexed[0]{$key} // [{}] }; 1701: 1702: for my $prow (@base_rows) { 1703: my %merged = %{$prow}; 1704: 1705: for my $i (1 .. $n - 1) { 1706: my $sec_arr = $indexed[$i]{$key}; 1707: next unless $sec_arr && @{$sec_arr}; 1708: 1709: # Write secondary columns directly into %merged without copying 1710: # the source row into a temporary hash first. 1711: # Before: %row_copy = %{$src} then %merged = (%merged,%row_copy) 1712: # → 2 full hash copies per secondary per row: O(C) + O(|merged|+C) 1713: # After: per-key loop writes straight into %merged 1714: # → O(C) key assignments only; no intermediate allocation 1715: my $src = $sec_arr->[-1]; 1716: my $local_jc = $self->{_join_map}{$i}; 1717: my $rename = $local_jc && $local_jc ne $join_col; 1718: for my $k (keys %{$src}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1719: if ($rename && $k eq $local_jc) { 1720: $merged{$join_col} = $src->{$k}; 1721: } else { 1722: $merged{$k} = $src->{$k}; 1723: } 1724: } 1725: } 1726: 1727: delete @merged{@removed} if @removed; 1728: push @result, \%merged; 1729: } 1730: } 1731:

Mutants (Total: 2, Killed: 2, Survived: 0)

1732: return \@result; 1733: } 1734: 1735: # _merge_criteria( \%base, \%extra ) -> \%merged 1736: # Purpose: Merge two criteria hashrefs for the same database column set. 1737: # Entry: %base is the permanent filter; %extra is the query-time criteria. 1738: # Exit: Returns a new hashref with both applied. 1739: # Merging rule: when both values for the same column are operator hashrefs 1740: # (e.g. { '>' => 60 } and { '<' => 365 }), the operators are combined 1741: # so both constraints apply simultaneously (AND semantics). 1742: # Otherwise the extra (query-time) value overwrites the base value. 1743: sub _merge_criteria :Protected { 1744 → 1746 → 1755 1744: my ($base, $extra) = @_; 1745: my %merged = %{$base}; 1746: for my $col (keys %{$extra}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1747: if (exists $merged{$col} 1748: && ref($merged{$col}) eq 'HASH' 1749: && ref($extra->{$col}) eq 'HASH') { 1750: $merged{$col} = { %{ $merged{$col} }, %{ $extra->{$col} } }; 1751: } else { 1752: $merged{$col} = $extra->{$col}; 1753: } 1754: }

Mutants (Total: 2, Killed: 2, Survived: 0)

1755: return \%merged; 1756: } 1757: 1758: # _msg( $i18n, $key, @sprintf_args ) -> $string 1759: # Purpose: Format a user-facing message, routing through the i18n object when 1760: # one is provided. Falls back to the built-in %MESSAGES dictionary. 1761: # Entry: $i18n may be undef. $key must be a key in %MESSAGES. 1762: # Exit: Returns the formatted string. 1763: sub _msg :Protected { 1764 → 1766 → 1770 1764: my ($i18n, $key, @args) = @_; 1765:

Mutants (Total: 1, Killed: 1, Survived: 0)

1766: if ($i18n && $i18n->can('translate')) {

Mutants (Total: 2, Killed: 2, Survived: 0)

1767: return $i18n->translate($key, @args); 1768: } 1769: 1770: my $fmt = $MESSAGES{$key} 1771: // sprintf($MESSAGES{error_unknown_message}, $key); 1772:

Mutants (Total: 2, Killed: 2, Survived: 0)

1773: return @args ? sprintf($fmt, @args) : $fmt; 1774: } 1775: 1776: 1; 1777: 1778: __END__ 1779: 1780: =head1 MESSAGES 1781: 1782: The following messages can be produced by C<Database::Join>. All messages 1783: can be localised by supplying an C<i18n> object to C<new>. 1784: 1785: =over 4 1786: 1787: =item C<error_no_databases> 1788: 1789: B<When:> The C<databases> arrayref passed to C<new> is empty. 1790: 1791: B<Fix:> Pass at least one C<Database::Abstraction> subclass object. 1792: 1793: =item C<error_invalid_db> 1794: 1795: B<When:> An element of the C<databases> array (or the argument to 1796: C<add_database>) is not an object, or is not a C<Database::Abstraction> 1797: subclass. 1798: 1799: B<Fix:> Instantiate the component database with its own C<new> method before 1800: passing it to C<Database::Join>. 1801: 1802: =item C<error_join_col_missing> 1803: 1804: B<When:> The join key column (or its C<join_map> alias) does not exist in one 1805: of the component databases. 1806: 1807: B<Fix:> Either add the column to the database, change C<join_column> to a 1808: column that is present everywhere, or use C<join_map> to declare the local 1809: alias for databases that call it something different. 1810: 1811: =item C<error_remove_join_col> 1812: 1813: B<When:> C<remove_column> is called with the name of the join key column. 1814: 1815: B<Fix:> The join key is required for the merge to work and cannot be hidden. 1816: Remove a different column. 1817: 1818: =item C<warn_unknown_column> (carp) 1819: 1820: B<When:> A criterion is passed for a column that does not exist in any 1821: component database (or has been removed with C<remove_column>). 1822: 1823: B<Fix:> Check the column name spelling. The criterion is ignored. 1824: 1825: =item C<error_query_unsupported> 1826: 1827: B<When:> C<query()> is called on a C<Database::Join> object. 1828: 1829: B<Fix:> Use C<selectall_arrayref>, C<selectall_array>, C<fetchrow_hashref>, 1830: or C<count> instead. 1831: 1832: =item C<error_execute_unsupported> 1833: 1834: B<When:> C<execute()> is called on a C<Database::Join> object. 1835: 1836: B<Fix:> Use the Perl-level query methods instead. Raw SQL cannot span 1837: heterogeneous database backends. 1838: 1839: =back 1840: 1841: =head1 REPOSITORY 1842: 1843: L<https://github.com/nigelhorne/Database-Join> 1844: 1845: =head1 SUPPORT 1846: 1847: This module is provided as-is without any warranty. 1848: 1849: =head1 SEE ALSO 1850: 1851: =over 4 1852: 1853: =item * L<Configure an Object at Runtime|Object::Configure> 1854: 1855: =item * L<Test Dashboard|https://nigelhorne.github.io/Database-Join/coverage/> 1856: 1857: =item * L<Database::Abstraction> 1858: 1859: =back 1860: 1861: =head1 SECURITY CONSIDERATIONS 1862: 1863: C<Database::Join> is a pure in-memory routing and merge layer. It never 1864: generates SQL strings, never opens files, and never calls C<system()>, 1865: C<exec()>, or C<eval()>. The security properties described below are 1866: architectural guarantees, not run-time checks. 1867: 1868: =head2 What Database::Join guarantees 1869: 1870: =over 4 1871: 1872: =item Criteria partition isolation 1873: 1874: Every criterion you pass to a query method is routed to I<exactly one> 1875: component database (the one that owns that column), or to I<all> databases 1876: when the criterion is on the join key column. A hostile value in a criterion 1877: for column C<name> (owned by database A) will never reach database B. 1878: 1879: =item Unknown columns are rejected before reaching any database 1880: 1881: If a criterion column name is not present in any component database (or has 1882: been hidden with C<remove_column>), C<Database::Join> logs a C<carp> warning 1883: and silently drops the criterion. No database receives the hostile key. 1884: 1885: =item AUTOLOAD only accepts word-character column names 1886: 1887: Perl's method dispatch extracts the column name via C<\w+>, which matches only 1888: C<[A-Za-z0-9_]>. Hostile method names with shell metacharacters, quotes, or 1889: spaces cannot reach the AUTOLOAD dispatch path. Private names (starting with 1890: C<_>) are additionally blocked with an explicit C<croak>. 1891: 1892: =item No value sanitisation (by design) 1893: 1894: C<Database::Join> does I<not> sanitise, HTML-encode, or validate the 1895: I<values> in criteria hashrefs. Preventing SQL injection is the 1896: responsibility of the underlying C<Database::Abstraction> objects (which use 1897: parameterised queries). Preventing XSS or header injection is the 1898: responsibility of the CGI or web layer that renders the output. 1899: 1900: =item Taint-mode compatible 1901: 1902: C<Database::Join> contains no C<system()>, C<exec()>, backtick, C<open(PIPE)>, 1903: or C<eval STRING> calls. It neither opens files nor constructs shell commands. 1904: The AUTOLOAD regex C</::(\w+)$/> produces an I<untainted> capture, so the 1905: column name used for dispatch is clean under C<-T>. Criteria values are 1906: passed verbatim to component C<Database::Abstraction> objects; those objects 1907: are responsible for handling tainted values at the SQL parameterisation layer. 1908: 1909: =item Operator hashref aliasing 1910: 1911: When the same join-key criterion (an operator hashref such as 1912: C<< { '>' => 'A' } >>) is broadcast to multiple component databases, all of 1913: them receive a reference to the I<same> hashref. A malicious component 1914: database that mutates the hashref's contents could affect what subsequent 1915: databases receive. Component databases are assumed to be trusted. 1916: 1917: =back 1918: 1919: =head2 What the caller is responsible for 1920: 1921: =over 4 1922: 1923: =item Sanitise values before building criteria 1924: 1925: DJ passes criterion values verbatim to component databases. If your 1926: application accepts user-supplied filter values (e.g. from a CGI query 1927: string), those values I<must> be validated or sanitised by your application 1928: before being passed to DJ. 1929: 1930: =item Restrict which columns the caller can filter on 1931: 1932: Any column in C<columns()> can be used as a filter criterion. If a column 1933: should not be filterable by end users (e.g. an internal status flag), hide it 1934: with C<remove_column> so that queries on it are silently dropped. 1935: 1936: =item Do not expose the joined view directly to user-supplied criteria 1937: 1938: DJ is not a firewall. It faithfully routes user input to component databases. 1939: Wrap DJ calls in a thin service layer that whitelists the permitted criterion 1940: columns and validates their values. 1941: 1942: =back 1943: 1944: =head3 API SPECIFICATION (security surface) 1945: 1946: Input accepted by all query methods and passed through DJ to component databases: 1947: 1948: Criterion values: 1949: type: scalar string | operator hashref { OP => scalar } 1950: validation: NONE (DJ trusts the caller; component DA is responsible) 1951: max size: unconstrained (OOM risk on very large values) 1952: 1953: Column name keys in criteria: 1954: type: string 1955: validation: must be present in _col_db (else carp + drop) 1956: character set: any Perl string (including control chars); DJ does 1957: not impose a character-set restriction on criteria KEYS 1958: 1959: AUTOLOAD method-name-as-column: 1960: type: \w+ (enforced by Perl regex /::(\w+)$/) 1961: validation: must not start with '_'; must be in _col_db 1962: 1963: =encoding UTF-8 1964: 1965: =head1 FORMAL SPECIFICATION 1966: 1967: Z calculus schemas for the key invariants and operations. 1968: Unicode is used throughout this section as required by Z notation. 1969: 1970: ─── Database_Join ───────────────────────────────────────────────── 1971: dbs : seq DATABASE_ABSTRACTION 1972: join_col : NAME 1973: join_type : {left, inner, outer} 1974: join_map : ℕ ⇸ NAME 1975: filters : ℕ ⇸ CRITERIA 1976: col_db : NAME ⇸ ℕ 1977: removed : ℙ NAME 1978: ─────────────────────────────────────────────────────────────────── 1979: #dbs ≥ 1 1980: dom join_map ⊆ 0 ‥ (#dbs - 1) 1981: dom filters ⊆ 0 ‥ (#dbs - 1) 1982: dom col_db = (⋃ { i : 0 ‥ #dbs-1 • ran((dbs i).columns) }) \ removed 1983: join_col ∉ removed 1984: ∀ i : 0 ‥ #dbs-1 • 1985: local_jc(i) = if i ∈ dom join_map then join_map(i) else join_col 1986: ∀ i : 0 ‥ #dbs-1 • 1987: local_jc(i) ∈ ran((dbs i).columns) 1988: 1989: ─── Init ────────────────────────────────────────────────────────── 1990: ΔDatabase_Join 1991: dbs? : seq DATABASE_ABSTRACTION 1992: join_col? : NAME 1993: join_type? : {left, inner, outer} 1994: join_map? : ℕ ⇸ NAME 1995: filters? : ℕ ⇸ CRITERIA 1996: removed? : ℙ NAME 1997: ─────────────────────────────────────────────────────────────────── 1998: #dbs? ≥ 1 1999: dbs' = dbs? 2000: join_col' = join_col? 2001: join_type'= join_type? 2002: join_map' = join_map? 2003: filters' = filters? 2004: col_db' = buildColIndex(dbs?, join_col?, join_map?) 2005: removed' = removed? 2006: 2007: ─── SelectAllArrayref ───────────────────────────────────────────── 2008: ΞDatabase_Join -- state unchanged 2009: criteria? : CRITERIA 2010: result! : seq MERGED_ROW 2011: ─────────────────────────────────────────────────────────────────── 2012: ∀ c : dom criteria? • c ∈ dom col_db ∪ {join_col} 2013: result! = joinedQuery(criteria?) 2014: result! is sorted ascending by join_col value 2015: 2016: ─── AddDatabase ─────────────────────────────────────────────────── 2017: ΔDatabase_Join 2018: db? : DATABASE_ABSTRACTION 2019: local_jc? : NAME -- optional; defaults to join_col 2020: filter? : CRITERIA -- optional 2021: remove? : ℙ NAME -- optional 2022: ─────────────────────────────────────────────────────────────────── 2023: db?.isa('Database::Abstraction') 2024: local_jc? ∈ ran(db?.columns) 2025: dbs' = dbs ^ ⟨db?⟩ 2026: col_db' = col_db ⊕ { c ↦ #dbs | c ∈ ran(db?.columns) \ {local_jc?} \ removed } 2027: filters' = if filter? ≠ ∅ then filters ⊕ {#dbs ↦ filter?} else filters 2028: join_map' = if local_jc? ≠ join_col 2029: then join_map ⊕ {#dbs ↦ local_jc?} 2030: else join_map 2031: removed' = removed ∪ remove? 2032: 2033: ─── RemoveColumn ────────────────────────────────────────────────── 2034: ΔDatabase_Join 2035: col? : NAME 2036: ─────────────────────────────────────────────────────────────────── 2037: col? ≠ join_col 2038: removed' = removed ∪ {col?} 2039: col_db' = col_db \ {col?} 2040: join_map' = join_map 2041: filters' = filters 2042: dbs' = dbs 2043: 2044: =head2 join_map 2045: 2046: ─── JoinMap ─────────────────────────────────────────────────────── 2047: join_map : ℕ ⇸ NAME 2048: dbs : seq DATABASE_ABSTRACTION 2049: join_col : NAME 2050: ─────────────────────────────────────────────────────────────────── 2051: dom join_map ⊆ 0 ‥ (#dbs - 1) 2052: ∀ i : dom join_map • (join_map i) ∈ ran(dbs i).columns 2053: ∀ i : 0 ‥ (#dbs - 1) \ dom join_map • 2054: join_col ∈ ran(dbs i).columns 2055: 2056: -- Resolution of the local join-key name for database i: 2057: local_jc(i) == if i ∈ dom join_map then join_map(i) else join_col 2058: 2059: -- The canonical name is always join_col; local_jc is never exposed. 2060: 2061: =head2 SECURITY INVARIANTS 2062: 2063: ─── PartitionIsolation ───────────────────────────────────────────── 2064: -- For every query call with criteria C and column col ≠ join_col: 2065: ∀ i : 0 ‥ #dbs-1 • 2066: i ≠ _col_db(col) ⟹ col ∉ dom(per_db(i)) 2067: 2068: -- Unknown column is dropped before any database sees it: 2069: col ∉ dom(_col_db) ∧ col ≠ join_col ⟹ 2070: (∀ i : 0 ‥ #dbs-1 • col ∉ dom(per_db(i))) 2071: 2072: ─── NoCodeExecution ──────────────────────────────────────────────── 2073: -- DJ contains no call to system(), exec(), open(PIPE), or eval(). 2074: -- Hostile criterion values therefore cannot achieve code execution 2075: -- within the Database::Join layer. 2076: ∀ v : VALUE • _joined_query({col ↦ v}) ≠ ⊥ due to code injection 2077: 2078: =head2 filters 2079: 2080: ─── Filters ───────────────────────────────────────────────────── 2081: filters : ℕ ⇸ CRITERIA 2082: dbs : seq DATABASE_ABSTRACTION 2083: ───────────────────────────────────────────────────────────────── 2084: dom filters ⊆ 0 ‥ (#dbs - 1) 2085: 2086: -- A filtered database i always contributes to key-set intersection. 2087: -- For each query with criteria C: 2088: effective_criteria(i, C) == 2089: if i ∈ dom filters 2090: then merge_criteria(filters(i), partition(C, i)) 2091: else partition(C, i) 2092: 2093: -- Criteria merging (AND semantics for operator hashrefs): 2094: merge_criteria(base, extra) == 2095: { col : dom base ∪ dom extra • 2096: if col ∈ dom base ∩ dom extra 2097: ∧ base(col) ∈ HASHREF ∧ extra(col) ∈ HASHREF 2098: then col ↦ base(col) ∪ extra(col) -- operator union 2099: else col ↦ (if col ∈ dom extra then extra(col) else base(col)) } 2100: 2101: =head2 selectall_arrayref 2102: 2103: selectall_arrayref : CRITERIA → seq MERGED_ROW 2104: pre: ∀ col : dom criteria • col ∈ dom self._col_db ∪ {self._join_col} 2105: post: result = _joined_query(criteria) 2106: result is sorted ascending by join_col value 2107: 2108: =head2 selectall_array 2109: 2110: selectall_array : CRITERIA → seq MERGED_ROW | MERGED_ROW? 2111: pre: same as selectall_arrayref 2112: post: wantarray => result = @{ selectall_arrayref(criteria) } 2113: !wantarray => result = selectall_arrayref(criteria)[0] (or undef) 2114: 2115: =head2 fetchrow_hashref 2116: 2117: fetchrow_hashref : CRITERIA → MERGED_ROW? 2118: post: result = selectall_arrayref(criteria)[0] (or undef if empty) 2119: 2120: =head2 count 2121: 2122: count : CRITERIA → ℕ 2123: post: result = #selectall_arrayref(criteria) 2124: 2125: =head2 columns 2126: 2127: columns : → seq NAME 2128: post: result = sort( 2129: (⋃ { i : 0 ‥ #dbs-1 • ran(dbs(i).columns) } 2130: \ dom removed_cols 2131: \ { local_jc(i) | i ∈ dom join_map ∧ local_jc(i) ≠ join_col }) 2132: ) 2133: 2134: =head2 schema 2135: 2136: schema : → NAME ⇸ SCHEMA_INFO 2137: post: dom(result) = ran(columns()) 2138: ∀ col : dom(result) • 2139: result(col) = (last database containing col).schema()(col) 2140: 2141: =head2 updated 2142: 2143: updated : → ℕ 2144: post: result = max { i : 0 ‥ #dbs-1 • dbs(i).updated() } 2145: 2146: =head2 remove_column 2147: 2148: remove_column : NAME → Database_Join 2149: pre: col ≠ self._join_col 2150: post: self'._removed_cols = self._removed_cols ∪ {col} 2151: self'._col_db = self._col_db \ {col} 2152: self'._col_cache = undef 2153: self'._schema_cache = undef 2154: 2155: =head2 AUTOLOAD 2156: 2157: AUTOLOAD : NAME × CRITERIA → VALUE | seq VALUE 2158: pre: col ∈ dom self._col_db 2159: col does not begin with '_' 2160: post: let rows = _joined_query(criteria) 2161: wantarray => result = { r : rows • r(col) } 2162: !wantarray => result = rows(0)(col) (or undef if rows is empty) 2163: 2164: =head1 AUTHOR 2165: 2166: Nigel Horne, C<< <njh@nigelhorne.com> >> 2167: 2168: =head1 LICENSE AND COPYRIGHT 2169: 2170: Copyright (C) 2026 Nigel Horne. 2171: 2172: Usage is subject to the GPL2 licence terms. 2173: If you use it, please let me know. 2174: 2175: =cut